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
+92 -63
View File
@@ -354,7 +354,7 @@ interface MessageState {
interface MessageActions {
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>;
abortCurrentOperation: (currentSessionId?: string) => Promise<void>;
_addStreamingPartImmediate: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string, currentSessionId?: string) => void;
@@ -579,7 +579,7 @@ export const useMessageStore = create<MessageStore>()(
});
},
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => {
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, currentSessionId?: string, attachments?: AttachedFile[], agentMentionName?: string | null, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => {
if (!currentSessionId) {
throw new Error("No session selected");
}
@@ -596,54 +596,47 @@ export const useMessageStore = create<MessageStore>()(
await executeWithSessionDirectory(sessionId, async () => {
try {
let effectiveContent = content;
const isCommand = content.startsWith("/");
if (isCommand) {
const spaceIndex = content.indexOf(" ");
const command = spaceIndex === -1 ? content.substring(1) : content.substring(1, spaceIndex);
const commandArgs = spaceIndex === -1 ? "" : content.substring(spaceIndex + 1).trim();
const apiClient = opencodeClient.getApiClient();
const directory = opencodeClient.getDirectory();
if (command === "init") {
const messageId = `msg_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
await apiClient.session.init({
sessionID: sessionId,
...(directory ? { directory } : {}),
messageID: messageId,
providerID,
modelID,
});
return;
}
if (command === "summarize") {
await apiClient.session.summarize({
sessionID: sessionId,
...(directory ? { directory } : {}),
providerID,
modelID,
});
return;
}
try {
const commandDetails = await opencodeClient.getCommandDetails(command);
if (commandDetails?.template) {
effectiveContent = commandDetails.template.replace(/\$ARGUMENTS/g, commandArgs);
} else {
effectiveContent = content;
}
} catch (error) {
console.error("Command template resolution failed:", error);
effectiveContent = content;
}
}
const trimmedContent = content.trimStart();
const commandPayload = (() => {
if (inputMode === 'shell') return null;
if (!trimmedContent.startsWith("/")) return null;
const firstLineEnd = trimmedContent.indexOf("\n");
const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd);
const [commandToken, ...firstLineArgs] = firstLine.split(" ");
const command = commandToken.slice(1).trim();
if (command.toLowerCase() === "shell") return null;
if (!command) return null;
const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1);
const argsFromFirstLine = firstLineArgs.join(" ").trim();
const args = restOfInput
? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput)
: argsFromFirstLine;
return {
command,
arguments: args,
};
})();
const shellPayload = (() => {
if (inputMode !== 'shell') return null;
const command = content.trim();
if (!command.trim()) return null;
return { command };
})();
const slashShellPayload = (() => {
if (!trimmedContent.startsWith("/")) return null;
const firstLineEnd = trimmedContent.indexOf("\n");
const firstLine = firstLineEnd === -1 ? trimmedContent : trimmedContent.slice(0, firstLineEnd);
const [commandToken, ...firstLineArgs] = firstLine.split(" ");
const commandName = commandToken.slice(1).trim().toLowerCase();
if (commandName !== "shell") return null;
const restOfInput = firstLineEnd === -1 ? "" : trimmedContent.slice(firstLineEnd + 1);
const argsFromFirstLine = firstLineArgs.join(" ").trim();
const command = restOfInput
? (argsFromFirstLine ? `${argsFromFirstLine}\n${restOfInput}` : restOfInput)
: argsFromFirstLine;
if (!command.trim()) return null;
return { command };
})();
set({
lastUsedProvider: { providerID, modelID },
@@ -723,17 +716,51 @@ export const useMessageStore = create<MessageStore>()(
})),
}));
await opencodeClient.sendMessage({
id: sessionId,
providerID,
modelID,
text: effectiveContent,
agent,
variant,
files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
});
const apiClient = opencodeClient.getApiClient();
const directory = opencodeClient.getDirectory();
if (shellPayload || slashShellPayload) {
await apiClient.session.shell({
sessionID: sessionId,
...(directory ? { directory } : {}),
...(agent ? { agent } : {}),
model: {
providerID,
modelID,
},
command: (shellPayload ?? slashShellPayload)!.command,
});
} else if (commandPayload && commandPayload.command.toLowerCase() === 'compact') {
await apiClient.session.summarize({
sessionID: sessionId,
...(directory ? { directory } : {}),
providerID,
modelID,
});
} else if (commandPayload) {
await opencodeClient.sendCommand({
id: sessionId,
providerID,
modelID,
command: commandPayload.command,
arguments: commandPayload.arguments,
agent,
variant,
files: filePayloads.length > 0 ? filePayloads : undefined,
});
} else {
await opencodeClient.sendMessage({
id: sessionId,
providerID,
modelID,
text: content,
agent,
variant,
files: filePayloads.length > 0 ? filePayloads : undefined,
additionalParts: additionalPartsPayload && additionalPartsPayload.length > 0 ? additionalPartsPayload : undefined,
agentMentions: agentMentionName ? [{ name: agentMentionName }] : undefined,
});
}
if (filePayloads.length > 0) {
try {
@@ -1145,7 +1172,8 @@ export const useMessageStore = create<MessageStore>()(
const incomingText = extractTextFromPart(part).trim();
const shouldKeep =
incomingText.startsWith('User has requested to enter plan mode') ||
incomingText.startsWith('The plan at ');
incomingText.startsWith('The plan at ') ||
incomingText.startsWith('The following tool was executed by the user');
if (!shouldKeep) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_user_part');
return state;
@@ -1226,7 +1254,8 @@ export const useMessageStore = create<MessageStore>()(
const incomingText = extractTextFromPart(part).trim();
const shouldKeep =
incomingText.startsWith('User has requested to enter plan mode') ||
incomingText.startsWith('The plan at ');
incomingText.startsWith('The plan at ') ||
incomingText.startsWith('The following tool was executed by the user');
if (!shouldKeep) {
(window as any).__messageTracker?.(messageId, 'skipped_synthetic_new_user_part');
return state;
+1 -1
View File
@@ -222,7 +222,7 @@ export interface SessionStore {
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise<void>;
abortCurrentOperation: () => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null;
+3 -3
View File
@@ -335,7 +335,7 @@ export const useSessionStore = create<SessionStore>()(
get().evictLeastRecentlyUsed();
},
loadMessages: (sessionId: string, limit?: number) => useMessageStore.getState().loadMessages(sessionId, limit),
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string) => {
sendMessage: async (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode: 'normal' | 'shell' = 'normal') => {
const draft = get().newSessionDraft;
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
@@ -420,7 +420,7 @@ export const useSessionStore = create<SessionStore>()(
try {
return await useMessageStore
.getState()
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant);
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, mergedAdditionalParts, variant, inputMode);
} catch (error) {
setStatus(created.id, 'idle');
throw error;
@@ -477,7 +477,7 @@ export const useSessionStore = create<SessionStore>()(
}
try {
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant);
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant, inputMode);
} catch (error) {
if (currentSessionId) {
setStatus(currentSessionId, 'idle');
@@ -13,7 +13,7 @@ import type {
SkillsCatalogSourceResponse,
} from '@/lib/api/types';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
const FALLBACK_SOURCES: SkillsCatalogSource[] = [
@@ -373,8 +373,14 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
return { ok: false, error };
}
// Refresh installed skills list.
void useSkillsStore.getState().loadSkills();
if (payload.requiresReload) {
await refreshSkillsAfterOpenCodeRestart({
message: payload.message,
delayMs: payload.reloadDelayMs,
});
} else {
void useSkillsStore.getState().loadSkills();
}
return payload;
} catch (error) {
+117 -9
View File
@@ -5,6 +5,7 @@ import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
@@ -136,6 +137,13 @@ declare global {
}
const CONFIG_EVENT_SOURCE = "useSkillsStore";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const MAX_HEALTH_WAIT_MS = 20000;
const FAST_HEALTH_POLL_INTERVAL_MS = 300;
const FAST_HEALTH_POLL_ATTEMPTS = 4;
const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
export const useSkillsStore = create<SkillsStore>()(
devtools(
@@ -211,6 +219,7 @@ export const useSkillsStore = create<SkillsStore>()(
createSkill: async (config: SkillConfig) => {
startConfigUpdate("Creating skill...");
let requiresReload = false;
try {
const skillConfig: Record<string, unknown> = {
name: config.name,
@@ -237,8 +246,16 @@ export const useSkillsStore = create<SkillsStore>()(
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const needsReload = payload?.requiresReload ?? false;
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
@@ -247,12 +264,15 @@ export const useSkillsStore = create<SkillsStore>()(
} catch {
return false;
} finally {
finishConfigUpdate();
if (!requiresReload) {
finishConfigUpdate();
}
}
},
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
startConfigUpdate("Updating skill...");
let requiresReload = false;
try {
const skillConfig: Record<string, unknown> = {};
@@ -275,8 +295,16 @@ export const useSkillsStore = create<SkillsStore>()(
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const needsReload = payload?.requiresReload ?? false;
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
@@ -285,12 +313,15 @@ export const useSkillsStore = create<SkillsStore>()(
} catch {
return false;
} finally {
finishConfigUpdate();
if (!requiresReload) {
finishConfigUpdate();
}
}
},
deleteSkill: async (name: string) => {
startConfigUpdate("Deleting skill...");
let requiresReload = false;
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
@@ -305,8 +336,16 @@ export const useSkillsStore = create<SkillsStore>()(
throw new Error(message);
}
// Skills are just files - no need to reload OpenCode
// Just refresh our local list
const needsReload = payload?.requiresReload ?? false;
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
message: payload?.message,
delayMs: payload?.reloadDelayMs,
});
return true;
}
const loaded = await get().loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
@@ -320,7 +359,9 @@ export const useSkillsStore = create<SkillsStore>()(
} catch {
return false;
} finally {
finishConfigUpdate();
if (!requiresReload) {
finishConfigUpdate();
}
}
},
@@ -402,6 +443,73 @@ if (typeof window !== "undefined") {
window.__zustand_skills_store__ = useSkillsStore;
}
async function waitForOpenCodeConnection(delayMs?: number) {
const initialPause = typeof delayMs === "number" && delayMs > 0
? Math.min(delayMs, FAST_HEALTH_POLL_INTERVAL_MS)
: 0;
if (initialPause > 0) {
await sleep(initialPause);
}
const start = Date.now();
let attempt = 0;
let lastError: unknown = null;
while (Date.now() - start < MAX_HEALTH_WAIT_MS) {
attempt += 1;
updateConfigUpdateMessage(`Waiting for OpenCode… (attempt ${attempt})`);
try {
const isHealthy = await opencodeClient.checkHealth();
if (isHealthy) {
return;
}
lastError = new Error("OpenCode health check reported not ready");
} catch (error) {
lastError = error;
}
const elapsed = Date.now() - start;
const waitMs =
attempt <= FAST_HEALTH_POLL_ATTEMPTS && elapsed < 1200
? FAST_HEALTH_POLL_INTERVAL_MS
: Math.min(
SLOW_HEALTH_POLL_BASE_MS +
Math.max(0, attempt - FAST_HEALTH_POLL_ATTEMPTS) * SLOW_HEALTH_POLL_INCREMENT_MS,
SLOW_HEALTH_POLL_MAX_MS,
);
await sleep(waitMs);
}
throw lastError || new Error("OpenCode did not become ready in time");
}
export async function refreshSkillsAfterOpenCodeRestart(options?: { message?: string; delayMs?: number }) {
try {
updateConfigUpdateMessage(options?.message || "Refreshing skills…");
} catch {
// ignore
}
try {
await waitForOpenCodeConnection(options?.delayMs);
updateConfigUpdateMessage("Refreshing skills…");
const skillsStore = useSkillsStore.getState();
const loaded = await skillsStore.loadSkills();
if (loaded) {
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
}
} catch {
updateConfigUpdateMessage("OpenCode refresh failed. Please retry.");
await sleep(1500);
} finally {
finishConfigUpdate();
}
}
// Subscribe to config changes from other stores
let unsubscribeSkillsConfigChanges: (() => void) | null = null;