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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user