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:
Bohdan Triapitsyn
2026-02-18 20:08:42 +02:00
committed by GitHub
parent e4a2486312
commit 85f21cb945
23 changed files with 1557 additions and 318 deletions
+245 -10
View File
@@ -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;
}