feat(chat): align command, shell, and subtask UX (#444)
* feat: reload interface after skills operations - Adds configurable delay before interface reload after skills changes - Introduces polling to wait for application health after reload - Updates UI to show reload message when installing or modifying skills * feat: distinguish skills from commands in UI - Displays skill badge for commands that are registered skills - Prevents editing skills through command management interface - Triggers interface reload after skill operations to reflect changes * feat(chat): align command and subtask UX with opencode parity Route commands/shell via parity paths, render delegated subtasks cleanly, and surface child-session permission/question prompts in parent chat. * fix(ProjectEditDialog): improve layout consistency * fix: update task icon and session handling in ToolPart * feat(chat): add shell-mode input and collapse shell bridge output Switch leading ! to shell mode UX and fold synthetic shell bridge assistant messages into the user shell bubble with inline output actions. * fix: remove AI agent icon from file mention autocomplete * fix: remove unused icon import from file mention component
This commit is contained in:
committed by
GitHub
parent
e4a2486312
commit
85f21cb945
@@ -18,6 +18,221 @@ interface ChatMessageEntry {
|
||||
parts: Part[];
|
||||
}
|
||||
|
||||
const USER_SHELL_MARKER = 'The following tool was executed by the user';
|
||||
|
||||
const resolveMessageRole = (message: ChatMessageEntry): string | null => {
|
||||
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
|
||||
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
||||
?? (typeof info.role === 'string' ? info.role : null)
|
||||
?? null;
|
||||
};
|
||||
|
||||
const isUserSubtaskMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||
if (!message) return false;
|
||||
if (resolveMessageRole(message) !== 'user') return false;
|
||||
return message.parts.some((part) => part?.type === 'subtask');
|
||||
};
|
||||
|
||||
const getMessageId = (message: ChatMessageEntry | undefined): string | null => {
|
||||
if (!message) return null;
|
||||
const id = (message.info as unknown as { id?: unknown }).id;
|
||||
return typeof id === 'string' && id.trim().length > 0 ? id : null;
|
||||
};
|
||||
|
||||
const getMessageParentId = (message: ChatMessageEntry): string | null => {
|
||||
const parentID = (message.info as unknown as { parentID?: unknown }).parentID;
|
||||
return typeof parentID === 'string' && parentID.trim().length > 0 ? parentID : null;
|
||||
};
|
||||
|
||||
const isUserShellMarkerMessage = (message: ChatMessageEntry | undefined): boolean => {
|
||||
if (!message) return false;
|
||||
if (resolveMessageRole(message) !== 'user') return false;
|
||||
|
||||
return message.parts.some((part) => {
|
||||
if (part?.type !== 'text') return false;
|
||||
const text = (part as unknown as { text?: unknown }).text;
|
||||
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
|
||||
return synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER);
|
||||
});
|
||||
};
|
||||
|
||||
type ShellBridgeDetails = {
|
||||
command?: string;
|
||||
output?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const getShellBridgeAssistantDetails = (message: ChatMessageEntry, expectedParentId: string | null): { hide: boolean; details: ShellBridgeDetails | null } => {
|
||||
if (resolveMessageRole(message) !== 'assistant') {
|
||||
return { hide: false, details: null };
|
||||
}
|
||||
|
||||
if (expectedParentId && getMessageParentId(message) !== expectedParentId) {
|
||||
return { hide: false, details: null };
|
||||
}
|
||||
|
||||
if (message.parts.length !== 1) {
|
||||
return { hide: false, details: null };
|
||||
}
|
||||
|
||||
const part = message.parts[0] as unknown as {
|
||||
type?: unknown;
|
||||
tool?: unknown;
|
||||
state?: {
|
||||
status?: unknown;
|
||||
input?: { command?: unknown };
|
||||
output?: unknown;
|
||||
metadata?: { output?: unknown };
|
||||
};
|
||||
};
|
||||
|
||||
if (part.type !== 'tool') {
|
||||
return { hide: false, details: null };
|
||||
}
|
||||
|
||||
const toolName = typeof part.tool === 'string' ? part.tool.toLowerCase() : '';
|
||||
if (toolName !== 'bash') {
|
||||
return { hide: false, details: null };
|
||||
}
|
||||
|
||||
const command = typeof part.state?.input?.command === 'string' ? part.state.input.command : undefined;
|
||||
const output =
|
||||
(typeof part.state?.output === 'string' ? part.state.output : undefined)
|
||||
?? (typeof part.state?.metadata?.output === 'string' ? part.state.metadata.output : undefined);
|
||||
const status = typeof part.state?.status === 'string' ? part.state.status : undefined;
|
||||
|
||||
return {
|
||||
hide: true,
|
||||
details: {
|
||||
command,
|
||||
output,
|
||||
status,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readTaskSessionId = (toolPart: Part): string | null => {
|
||||
const partRecord = toolPart as unknown as {
|
||||
state?: {
|
||||
metadata?: { sessionId?: unknown; sessionID?: unknown };
|
||||
output?: unknown;
|
||||
};
|
||||
};
|
||||
const metadata = partRecord.state?.metadata;
|
||||
const fromMetadata =
|
||||
(typeof metadata?.sessionId === 'string' && metadata.sessionId.trim().length > 0
|
||||
? metadata.sessionId.trim()
|
||||
: null)
|
||||
?? (typeof metadata?.sessionID === 'string' && metadata.sessionID.trim().length > 0
|
||||
? metadata.sessionID.trim()
|
||||
: null);
|
||||
if (fromMetadata) return fromMetadata;
|
||||
|
||||
const output = partRecord.state?.output;
|
||||
if (typeof output === 'string') {
|
||||
const match = output.match(/task_id:\s*([a-zA-Z0-9_]+)/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isSyntheticSubtaskBridgeAssistant = (message: ChatMessageEntry): { hide: boolean; taskSessionId: string | null } => {
|
||||
if (resolveMessageRole(message) !== 'assistant') {
|
||||
return { hide: false, taskSessionId: null };
|
||||
}
|
||||
|
||||
if (message.parts.length !== 1) {
|
||||
return { hide: false, taskSessionId: null };
|
||||
}
|
||||
|
||||
const onlyPart = message.parts[0] as unknown as {
|
||||
type?: unknown;
|
||||
tool?: unknown;
|
||||
};
|
||||
|
||||
if (onlyPart.type !== 'tool') {
|
||||
return { hide: false, taskSessionId: null };
|
||||
}
|
||||
|
||||
const toolName = typeof onlyPart.tool === 'string' ? onlyPart.tool.toLowerCase() : '';
|
||||
if (toolName !== 'task') {
|
||||
return { hide: false, taskSessionId: null };
|
||||
}
|
||||
|
||||
return {
|
||||
hide: true,
|
||||
taskSessionId: readTaskSessionId(message.parts[0]),
|
||||
};
|
||||
};
|
||||
|
||||
const withSubtaskSessionId = (message: ChatMessageEntry, taskSessionId: string | null): ChatMessageEntry => {
|
||||
if (!taskSessionId) return message;
|
||||
const nextParts = message.parts.map((part) => {
|
||||
if (part?.type !== 'subtask') return part;
|
||||
const existing = (part as unknown as { taskSessionID?: unknown }).taskSessionID;
|
||||
if (typeof existing === 'string' && existing.trim().length > 0) return part;
|
||||
return {
|
||||
...part,
|
||||
taskSessionID: taskSessionId,
|
||||
} as Part;
|
||||
});
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: nextParts,
|
||||
};
|
||||
};
|
||||
|
||||
const withShellBridgeDetails = (message: ChatMessageEntry, details: ShellBridgeDetails | null): ChatMessageEntry => {
|
||||
const command = typeof details?.command === 'string' ? details.command.trim() : '';
|
||||
const output = typeof details?.output === 'string' ? details.output : '';
|
||||
const status = typeof details?.status === 'string' ? details.status.trim() : '';
|
||||
|
||||
const nextParts: Part[] = [];
|
||||
let injected = false;
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (!injected && part?.type === 'text') {
|
||||
const text = (part as unknown as { text?: unknown }).text;
|
||||
const synthetic = (part as unknown as { synthetic?: unknown }).synthetic;
|
||||
if (synthetic === true && typeof text === 'string' && text.trim().startsWith(USER_SHELL_MARKER)) {
|
||||
nextParts.push({
|
||||
type: 'text',
|
||||
text: '/shell',
|
||||
shellAction: {
|
||||
...(command ? { command } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
} as unknown as Part);
|
||||
injected = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextParts.push(part);
|
||||
}
|
||||
|
||||
if (!injected) {
|
||||
nextParts.push({
|
||||
type: 'text',
|
||||
text: '/shell',
|
||||
shellAction: {
|
||||
...(command ? { command } : {}),
|
||||
...(output ? { output } : {}),
|
||||
...(status ? { status } : {}),
|
||||
},
|
||||
} as unknown as Part);
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
parts: nextParts,
|
||||
};
|
||||
};
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessageEntry[];
|
||||
permissions: PermissionRequest[];
|
||||
@@ -215,7 +430,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
|
||||
const baseDisplayMessages = React.useMemo(() => {
|
||||
const seenIds = new Set<string>();
|
||||
return messages
|
||||
const normalizedMessages = messages
|
||||
.filter((message) => {
|
||||
const messageId = message.info?.id;
|
||||
if (typeof messageId === 'string') {
|
||||
@@ -238,6 +453,33 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
parts: filteredParts,
|
||||
};
|
||||
});
|
||||
|
||||
const output: ChatMessageEntry[] = [];
|
||||
|
||||
for (let index = 0; index < normalizedMessages.length; index += 1) {
|
||||
const current = normalizedMessages[index];
|
||||
const previous = output.length > 0 ? output[output.length - 1] : undefined;
|
||||
|
||||
if (isUserSubtaskMessage(previous)) {
|
||||
const bridge = isSyntheticSubtaskBridgeAssistant(current);
|
||||
if (bridge.hide) {
|
||||
output[output.length - 1] = withSubtaskSessionId(previous as ChatMessageEntry, bridge.taskSessionId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUserShellMarkerMessage(previous)) {
|
||||
const bridge = getShellBridgeAssistantDetails(current, getMessageId(previous));
|
||||
if (bridge.hide) {
|
||||
output[output.length - 1] = withShellBridgeDetails(previous as ChatMessageEntry, bridge.details);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
output.push(current);
|
||||
}
|
||||
|
||||
return output;
|
||||
}, [messages]);
|
||||
|
||||
const activeRetryStatus = useSessionStore(
|
||||
@@ -266,16 +508,9 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
data: { message: activeRetryStatus.message },
|
||||
};
|
||||
|
||||
const resolveRole = (message: ChatMessageEntry): string | null => {
|
||||
const info = message.info as unknown as { clientRole?: string | null | undefined; role?: string | null | undefined };
|
||||
return (typeof info.clientRole === 'string' ? info.clientRole : null)
|
||||
?? (typeof info.role === 'string' ? info.role : null)
|
||||
?? null;
|
||||
};
|
||||
|
||||
let lastUserIndex = -1;
|
||||
for (let index = baseDisplayMessages.length - 1; index >= 0; index -= 1) {
|
||||
if (resolveRole(baseDisplayMessages[index]) === 'user') {
|
||||
if (resolveMessageRole(baseDisplayMessages[index]) === 'user') {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
@@ -289,7 +524,7 @@ const MessageList: React.FC<MessageListProps> = ({
|
||||
// to avoid rendering a separate header-only placeholder + error block.
|
||||
let targetAssistantIndex = -1;
|
||||
for (let index = baseDisplayMessages.length - 1; index > lastUserIndex; index -= 1) {
|
||||
if (resolveRole(baseDisplayMessages[index]) === 'assistant') {
|
||||
if (resolveMessageRole(baseDisplayMessages[index]) === 'assistant') {
|
||||
targetAssistantIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user