fix(team): refine launch dialog previews
This commit is contained in:
parent
0f9f79da66
commit
472a1501ad
6 changed files with 260 additions and 21 deletions
|
|
@ -1599,11 +1599,101 @@ 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: 'Bash result',
|
title: 'Bash result',
|
||||||
preview: 'Tests passed',
|
preview: 'Tests passed - pnpm test',
|
||||||
});
|
});
|
||||||
expect(result.items).toHaveLength(1);
|
expect(result.items).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps successful file tool results compact with input context', () => {
|
||||||
|
const result = extractMemberLogPreviewItems({
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
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: 'src/app.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
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: 'export function app() { return true; }',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
kind: 'tool_result',
|
||||||
|
title: 'Read result',
|
||||||
|
preview: 'src/app.ts - export function app() { return true; }',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps empty successful file tool results readable without duplicate input rows', () => {
|
||||||
|
const result = extractMemberLogPreviewItems({
|
||||||
|
provider: 'claude_transcript',
|
||||||
|
maxItems: 3,
|
||||||
|
textLimit: 160,
|
||||||
|
messages: [
|
||||||
|
message({
|
||||||
|
uuid: 'edit-call',
|
||||||
|
timestamp: '2026-04-01T10:00:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_use',
|
||||||
|
id: 'tool-edit',
|
||||||
|
name: 'Edit',
|
||||||
|
input: {
|
||||||
|
file_path: 'src/app.ts',
|
||||||
|
old_string: 'a',
|
||||||
|
new_string: 'b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
message({
|
||||||
|
uuid: 'edit-result',
|
||||||
|
type: 'user',
|
||||||
|
role: 'user',
|
||||||
|
timestamp: '2026-04-01T10:01:00.000Z',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'tool-edit',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
kind: 'tool_result',
|
||||||
|
title: 'Edit result',
|
||||||
|
preview: 'src/app.ts',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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',
|
||||||
|
|
|
||||||
|
|
@ -522,6 +522,102 @@ function formatGenericToolResultTitle(
|
||||||
return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`;
|
return `${formatToolTitle(toolContext.name)} ${isError ? 'error' : 'result'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatShellResultContext(toolContext: ToolUseContext | undefined): string | null {
|
||||||
|
if (
|
||||||
|
!toolContext ||
|
||||||
|
(toolContext.canonicalName !== 'bash' && toolContext.canonicalName !== 'shell')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const input = asRecord(toolContext.input);
|
||||||
|
return stringField(input, 'description') ?? stringField(input, 'command');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addContextToSuccessResultPreview(
|
||||||
|
preview: ValuePreview,
|
||||||
|
context: string | null,
|
||||||
|
limit: number,
|
||||||
|
order: 'context-first' | 'result-first'
|
||||||
|
): ValuePreview {
|
||||||
|
if (!context) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
const compactContext = compactWhitespace(context);
|
||||||
|
if (!compactContext) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
if (!preview.preview) {
|
||||||
|
const fallback = truncatePreview(compactContext, limit);
|
||||||
|
return {
|
||||||
|
...fallback,
|
||||||
|
truncated: preview.truncated || fallback.truncated,
|
||||||
|
...(preview.title ? { title: preview.title } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const compactPreview = compactWhitespace(preview.preview);
|
||||||
|
if (!compactContext || compactPreview.toLowerCase().startsWith(compactContext.toLowerCase())) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
const combinedText =
|
||||||
|
order === 'context-first'
|
||||||
|
? `${compactContext} - ${compactPreview}`
|
||||||
|
: `${compactPreview} - ${compactContext}`;
|
||||||
|
const combined = truncatePreview(combinedText, limit);
|
||||||
|
return {
|
||||||
|
...combined,
|
||||||
|
truncated: preview.truncated || combined.truncated,
|
||||||
|
...(preview.title ? { title: preview.title } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileToolResultContext(toolContext: ToolUseContext | undefined): string | null {
|
||||||
|
if (!toolContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const input = asRecord(toolContext.input);
|
||||||
|
const path =
|
||||||
|
stringField(input, 'file_path') ??
|
||||||
|
stringField(input, 'filePath') ??
|
||||||
|
stringField(input, 'path') ??
|
||||||
|
stringField(input, 'cwd');
|
||||||
|
if (toolContext.canonicalName === 'grep') {
|
||||||
|
const query = stringField(input, 'query') ?? stringField(input, 'pattern');
|
||||||
|
if (query && path) return `${query} in ${path}`;
|
||||||
|
return query ?? path;
|
||||||
|
}
|
||||||
|
if (toolContext.canonicalName === 'glob') {
|
||||||
|
const pattern = stringField(input, 'pattern') ?? stringField(input, 'glob');
|
||||||
|
if (pattern && path) return `${pattern} in ${path}`;
|
||||||
|
return pattern ?? path;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
toolContext.canonicalName === 'read' ||
|
||||||
|
toolContext.canonicalName === 'write' ||
|
||||||
|
toolContext.canonicalName === 'edit' ||
|
||||||
|
toolContext.canonicalName === 'ls'
|
||||||
|
) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToolContextToSuccessResultPreview(
|
||||||
|
preview: ValuePreview,
|
||||||
|
toolContext: ToolUseContext | undefined,
|
||||||
|
limit: number
|
||||||
|
): ValuePreview {
|
||||||
|
const shellContext = formatShellResultContext(toolContext);
|
||||||
|
if (shellContext) {
|
||||||
|
return addContextToSuccessResultPreview(preview, shellContext, limit, 'result-first');
|
||||||
|
}
|
||||||
|
return addContextToSuccessResultPreview(
|
||||||
|
preview,
|
||||||
|
formatFileToolResultContext(toolContext),
|
||||||
|
limit,
|
||||||
|
'context-first'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildToolUseKey(input: {
|
function buildToolUseKey(input: {
|
||||||
provider: MemberLogStreamProvider;
|
provider: MemberLogStreamProvider;
|
||||||
sourceId: string;
|
sourceId: string;
|
||||||
|
|
@ -535,6 +631,12 @@ function isToolUseSupersededBySuccessResult(toolName: string): boolean {
|
||||||
return (
|
return (
|
||||||
canonical === 'bash' ||
|
canonical === 'bash' ||
|
||||||
canonical === 'shell' ||
|
canonical === 'shell' ||
|
||||||
|
canonical === 'read' ||
|
||||||
|
canonical === 'write' ||
|
||||||
|
canonical === 'edit' ||
|
||||||
|
canonical === 'grep' ||
|
||||||
|
canonical === 'glob' ||
|
||||||
|
canonical === 'ls' ||
|
||||||
canonical === 'sendmessage' ||
|
canonical === 'sendmessage' ||
|
||||||
canonical === 'message_send' ||
|
canonical === 'message_send' ||
|
||||||
canonical.startsWith('cross_team_') ||
|
canonical.startsWith('cross_team_') ||
|
||||||
|
|
@ -2057,13 +2159,16 @@ function collectToolResultCandidates(input: {
|
||||||
sourceId: input.sourceId,
|
sourceId: input.sourceId,
|
||||||
toolUseId: id,
|
toolUseId: id,
|
||||||
});
|
});
|
||||||
const preview = previewUnknownValue(
|
const rawPreview = previewUnknownValue(
|
||||||
result.content,
|
result.content,
|
||||||
input.textLimit,
|
input.textLimit,
|
||||||
TOOL_RESULT_PRIORITY_KEYS,
|
TOOL_RESULT_PRIORITY_KEYS,
|
||||||
toolContext
|
toolContext
|
||||||
);
|
);
|
||||||
const isError = result.isError === true || preview.title === 'Tool error';
|
const isError = result.isError === true || rawPreview.title === 'Tool error';
|
||||||
|
const preview = isError
|
||||||
|
? rawPreview
|
||||||
|
: addToolContextToSuccessResultPreview(rawPreview, toolContext, input.textLimit);
|
||||||
const title =
|
const title =
|
||||||
preview.title === 'Tool error'
|
preview.title === 'Tool error'
|
||||||
? formatGenericToolResultTitle(toolContext, true)
|
? formatGenericToolResultTitle(toolContext, true)
|
||||||
|
|
|
||||||
|
|
@ -412,6 +412,7 @@ export const CreateTeamDialog = ({
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
|
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
||||||
const lastAutoDescriptionRef = useRef<string | null>(null);
|
const lastAutoDescriptionRef = useRef<string | null>(null);
|
||||||
const [fieldErrors, setFieldErrors] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
|
|
@ -1092,17 +1093,35 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) {
|
||||||
if (cwdMode !== 'project') {
|
appliedDefaultProjectPathRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedProjectPath) {
|
if (cwdMode !== 'project') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
||||||
if (selectableProjects.length === 0) {
|
if (selectableProjects.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||||
|
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||||
|
const defaultAlreadyApplied =
|
||||||
|
appliedDefaultProjectPathRef.current === normalizedDefaultProjectPath;
|
||||||
|
const match = selectableProjects.find(
|
||||||
|
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
|
||||||
|
);
|
||||||
|
if (match && !defaultAlreadyApplied) {
|
||||||
|
appliedDefaultProjectPathRef.current = normalizedDefaultProjectPath;
|
||||||
|
if (normalizePath(selectedProjectPath) !== normalizedDefaultProjectPath) {
|
||||||
|
setSelectedProjectPath(match.path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedProjectPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||||
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||||
const match = selectableProjects.find(
|
const match = selectableProjects.find(
|
||||||
|
|
@ -1678,7 +1697,7 @@ export const CreateTeamDialog = ({
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
{initialData
|
{initialData
|
||||||
? 'Create a new team based on an existing one.'
|
? 'Create a new team based on an existing one.'
|
||||||
: 'Team provisioning via local Claude CLI.'}
|
: 'Set up your team and choose how it starts.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -2188,7 +2207,8 @@ export const CreateTeamDialog = ({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="lg"
|
||||||
|
className="min-w-32 text-sm"
|
||||||
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -444,6 +444,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
|
const appliedDefaultProjectPathRef = useRef<string | null>(null);
|
||||||
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
|
const storeMembers = useStore((s) => selectResolvedMembersForTeamName(s, s.selectedTeamName));
|
||||||
const previousLaunchParams = useStore((s) =>
|
const previousLaunchParams = useStore((s) =>
|
||||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||||
|
|
@ -1628,9 +1629,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// Pre-select defaultProjectPath (launch mode) or first project
|
// Pre-select defaultProjectPath (launch mode) or first project
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || cwdMode !== 'project' || selectedProjectPath) return;
|
if (!open) {
|
||||||
|
appliedDefaultProjectPathRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cwdMode !== 'project') return;
|
||||||
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
const selectableProjects = projects.filter((project) => !isEphemeralProjectPath(project.path));
|
||||||
if (selectableProjects.length === 0) return;
|
if (selectableProjects.length === 0) return;
|
||||||
|
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||||
|
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||||
|
const defaultAlreadyApplied =
|
||||||
|
appliedDefaultProjectPathRef.current === normalizedDefaultProjectPath;
|
||||||
|
const match = selectableProjects.find(
|
||||||
|
(p) => normalizePath(p.path) === normalizedDefaultProjectPath
|
||||||
|
);
|
||||||
|
if (match && !defaultAlreadyApplied) {
|
||||||
|
appliedDefaultProjectPathRef.current = normalizedDefaultProjectPath;
|
||||||
|
if (normalizePath(selectedProjectPath) !== normalizedDefaultProjectPath) {
|
||||||
|
setSelectedProjectPath(match.path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectedProjectPath) return;
|
||||||
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
if (defaultProjectPath && !isEphemeralProjectPath(defaultProjectPath)) {
|
||||||
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
const normalizedDefaultProjectPath = normalizePath(defaultProjectPath);
|
||||||
const match = selectableProjects.find(
|
const match = selectableProjects.find(
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,16 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
||||||
return <span key={`${part}-${index}`}>{part}</span>;
|
return <span key={`${part}-${index}`}>{part}</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<mark
|
<span
|
||||||
key={`${part}-${index}`}
|
key={`${part}-${index}`}
|
||||||
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
|
className="rounded px-0.5 font-semibold text-[var(--color-text)]"
|
||||||
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
|
style={{
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--color-accent) 35%, transparent)',
|
||||||
|
boxShadow: 'inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 45%, transparent)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{part}
|
{part}
|
||||||
</mark>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,14 @@ export const MembersEditorSection = ({
|
||||||
{disableAddMember && addMemberLockReason ? (
|
{disableAddMember && addMemberLockReason ? (
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{jsonEditorOpen && showJsonEditor ? (
|
||||||
|
<MembersJsonEditor
|
||||||
|
value={jsonText}
|
||||||
|
onChange={handleJsonChange}
|
||||||
|
error={jsonError}
|
||||||
|
onClose={toggleJsonEditor}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{activeMembers.map((member, index) => (
|
{activeMembers.map((member, index) => (
|
||||||
<MemberDraftRow
|
<MemberDraftRow
|
||||||
|
|
@ -470,14 +478,6 @@ export const MembersEditorSection = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{jsonEditorOpen && showJsonEditor ? (
|
|
||||||
<MembersJsonEditor
|
|
||||||
value={jsonText}
|
|
||||||
onChange={handleJsonChange}
|
|
||||||
error={jsonError}
|
|
||||||
onClose={toggleJsonEditor}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{hasDuplicates ? (
|
{hasDuplicates ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue