From 2ad82fe2c48f236d29db959eb399ed4ed5cdf381 Mon Sep 17 00:00:00 2001 From: touch2be Date: Sun, 14 Jun 2026 23:49:32 +0200 Subject: [PATCH 1/5] fix: surface harness session errors --- src/App.tsx | 46 ++++++++++++++++ src/agents/shared.test.ts | 17 ++++++ src/agents/shared.ts | 4 ++ src/hooks/agent-backend-events.test.ts | 17 ++++-- src/hooks/agent-backend-events.ts | 10 ++-- src/hooks/agent-contexts.ts | 1 + src/hooks/agent-initial-state.ts | 1 + src/hooks/agent-local-intent.test.ts | 1 + src/hooks/agent-reducer.test.ts | 76 ++++++++++++++++++++++++++ src/hooks/agent-reducer.ts | 64 +++++++++++++++++++++- src/hooks/agent-state-types.ts | 2 + src/hooks/use-agent-impl-core.tsx | 2 + src/i18n/locales/de.json | 5 ++ src/i18n/locales/en.json | 5 ++ src/i18n/locales/es.json | 5 ++ 15 files changed, 246 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 42d73b1..d9687b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { MergeDialog } from "@/components/MergeDialog"; import { QueueList } from "@/components/QueueList"; import { UpdateDialog } from "@/components/UpdateDialog"; import { NoProjectConnected, NoSessionSelected } from "@/components/EmptyChatStates"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { Toaster } from "@/components/ui/sonner"; @@ -40,6 +41,12 @@ import { SetupWizard } from "./components/SetupWizard"; import { TitleBar } from "./components/TitleBar"; import "./index.css"; +function extractTerminalCommand(message: string | null) { + if (!message) return null; + const match = message.match(/\bRun\s+['"`]([^'"`]+)['"`]\s+in\s+(?:your\s+)?terminal/i); + return match?.[1]?.trim() || null; +} + function AppContent({ detachedProject, suppressBootErrors, @@ -75,6 +82,7 @@ function AppContent({ isLoadingMessages, activeTargetDirectory, sessionMeta, + sessionErrors, } = useSessionState(); const { messages } = useMessages(); const { providers, selectedModel, providerDefaults } = useModelState(); @@ -93,6 +101,11 @@ function AppContent({ [bootLogs], ); const activeSessionId = sessionActiveId; + const activeSessionError = activeSessionId ? (sessionErrors[activeSessionId] ?? null) : null; + const activeSessionErrorCommand = useMemo( + () => extractTerminalCommand(activeSessionError), + [activeSessionError], + ); const { activeSession, activeSessionDirectory, @@ -178,6 +191,13 @@ function AppContent({ const contextPercent = contextInfo.percent; + const openTerminalForSessionError = useCallback(() => { + if (!activeSessionDirectory) return; + void getDesktopShellClient() + .system.openInTerminal(activeSessionDirectory) + .catch((error) => console.error(error)); + }, [activeSessionDirectory]); + // Check for app updates on startup const updateCheck = useUpdateCheck(); @@ -256,6 +276,32 @@ function AppContent({ {showPromptBox && (
+ {activeSessionError && ( + + {t("sessionError.title")} + +

{activeSessionError}

+ {activeSessionErrorCommand && ( +
+

{t("sessionError.nextStep")}

+ + {activeSessionErrorCommand} + +
+ )} + {activeSessionErrorCommand && activeSessionDirectory && ( + + )} +
+
+ )} {queuedPrompts.length > 0 && (
{ }); }); + test("prefixes session error ids", () => { + const event = { + type: "pi:event", + payload: { + type: "session.error", + sessionID: "raw-session", + error: "Claude auth expired", + }, + } as unknown as NativeBackendEvent; + + expect(normalizeTaggedBackendEvent("pi", event, "pi:event")).toEqual({ + type: "session.error", + sessionID: "pi:raw-session", + error: "Claude auth expired", + }); + }); + test("ignores unrelated native event channels", () => { const event = { type: "pi:event", diff --git a/src/agents/shared.ts b/src/agents/shared.ts index 44c3c5c..098e39b 100644 --- a/src/agents/shared.ts +++ b/src/agents/shared.ts @@ -158,6 +158,10 @@ function normalizeBackendEventPayload(harnessId: HarnessId, payload: HarnessEven case "permission.cleared": case "question.cleared": return { ...payload, sessionID: codec.compose(payload.sessionID) }; + case "session.error": + return payload.sessionID + ? { ...payload, sessionID: codec.compose(payload.sessionID) } + : payload; case "permission.requested": return { ...payload, diff --git a/src/hooks/agent-backend-events.test.ts b/src/hooks/agent-backend-events.test.ts index 2a51d47..498a63a 100644 --- a/src/hooks/agent-backend-events.test.ts +++ b/src/hooks/agent-backend-events.test.ts @@ -166,7 +166,7 @@ describe("handleHarnessEvent", () => { expect(actions).toEqual([{ type: "SESSION_DELETED", payload: "session-1" }]); }); - test("surfaces only global session errors", () => { + test("surfaces session-scoped and global session errors", () => { const actions: Array> = []; const dispatch = (action: Record) => { actions.push(action); @@ -174,12 +174,12 @@ describe("handleHarnessEvent", () => { const sessionScoped: HarnessEvent = { type: "session.error", - error: "hidden", + error: "visible in session", sessionID: "session-1", }; const globalError: HarnessEvent = { type: "session.error", - error: "visible", + error: "visible globally", }; handleHarnessEvent({ @@ -199,6 +199,15 @@ describe("handleHarnessEvent", () => { dispatch: dispatch as never, }); - expect(actions).toEqual([{ type: "SET_ERROR", payload: "visible" }]); + expect(actions).toEqual([ + { + type: "SESSION_ERROR", + payload: { sessionID: "session-1", error: "visible in session" }, + }, + { + type: "SESSION_ERROR", + payload: { sessionID: undefined, error: "visible globally" }, + }, + ]); }); }); diff --git a/src/hooks/agent-backend-events.ts b/src/hooks/agent-backend-events.ts index c0c15fe..4b73d72 100644 --- a/src/hooks/agent-backend-events.ts +++ b/src/hooks/agent-backend-events.ts @@ -62,7 +62,8 @@ type BackendEventDispatch = ( type: "SET_QUESTION"; payload: QuestionRequest | { sessionID: string; clear: true }; } - | { type: "SET_ERROR"; payload: string | null }, + | { type: "SET_ERROR"; payload: string | null } + | { type: "SESSION_ERROR"; payload: { sessionID?: string; error: string } }, ) => void; const seenDeltaEventIds = new Set(); @@ -285,9 +286,10 @@ export function handleHarnessEvent({ }); return; case "session.error": - if (!event.sessionID) { - dispatch({ type: "SET_ERROR", payload: event.error }); - } + dispatch({ + type: "SESSION_ERROR", + payload: { sessionID: event.sessionID, error: event.error }, + }); return; } } diff --git a/src/hooks/agent-contexts.ts b/src/hooks/agent-contexts.ts index ccf40b9..9c09a8e 100644 --- a/src/hooks/agent-contexts.ts +++ b/src/hooks/agent-contexts.ts @@ -40,6 +40,7 @@ export interface SessionContextValue { unreadSessionIds: Set; sessionDrafts: Record; sessionMeta: SessionMetaMap; + sessionErrors: Record; } export interface MessagesContextValue { diff --git a/src/hooks/agent-initial-state.ts b/src/hooks/agent-initial-state.ts index c5c38fa..3eb1c2f 100644 --- a/src/hooks/agent-initial-state.ts +++ b/src/hooks/agent-initial-state.ts @@ -27,6 +27,7 @@ export const initialAgentState: InternalAgentState = { pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, diff --git a/src/hooks/agent-local-intent.test.ts b/src/hooks/agent-local-intent.test.ts index 235a92e..28e5f5b 100644 --- a/src/hooks/agent-local-intent.test.ts +++ b/src/hooks/agent-local-intent.test.ts @@ -20,6 +20,7 @@ function makeState(overrides: Partial = {}): InternalAgentSt pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, diff --git a/src/hooks/agent-reducer.test.ts b/src/hooks/agent-reducer.test.ts index 71bdd7c..4d165cb 100644 --- a/src/hooks/agent-reducer.test.ts +++ b/src/hooks/agent-reducer.test.ts @@ -33,6 +33,7 @@ function baseState(overrides: Partial = {}): InternalAgentSt pendingPermissions: {}, pendingQuestions: {}, lastError: null, + sessionErrors: {}, bootState: "idle", bootError: null, bootLogs: null, @@ -135,6 +136,81 @@ describe("mergeProjectBackendSessions", () => { expect(selected.isBusy).toBe(true); }); + test("session error stops active turn and records message", () => { + const sessionId = "pi:session-1"; + const running = reducer( + baseState({ activeSessionId: sessionId, sessions: [session(sessionId, "pi")] }), + { + type: "TURN_RUN_STARTED", + payload: { + id: "turn-1", + sessionID: sessionId, + startedAt: 1, + status: "running", + }, + } as Parameters[1], + ); + + const next = reducer(running, { + type: "SESSION_ERROR", + payload: { sessionID: sessionId, error: "Claude auth expired" }, + } as Parameters[1]); + + expect(next.isBusy).toBe(false); + expect(next.busySessionIds.has(sessionId)).toBe(false); + expect(next.sessionErrors[sessionId]).toBe("Claude auth expired"); + expect(next.lastError).toBe("Claude auth expired"); + expect(next.turnRuns["turn-1"]?.status).toBe("error"); + }); + + test("retry session status keeps session busy and records message", () => { + const sessionId = "opencode:session-1"; + const next = reducer(baseState({ activeSessionId: sessionId }), { + type: "SESSION_STATUS", + payload: { + sessionID: sessionId, + status: { + type: "retry", + attempt: 4, + message: "Claude authentication expired or invalid. Run 'claude login' in your terminal.", + next: Date.now() + 9000, + }, + }, + } as Parameters[1]); + + expect(next.isBusy).toBe(true); + expect(next.busySessionIds.has(sessionId)).toBe(true); + expect(next.sessionErrors[sessionId]).toContain("claude login"); + }); + + test("idle session status clears retry message", () => { + const sessionId = "opencode:session-1"; + const next = reducer( + baseState({ activeSessionId: sessionId, sessionErrors: { [sessionId]: "retrying" } }), + { + type: "SESSION_STATUS", + payload: { sessionID: sessionId, status: { type: "idle" } }, + } as Parameters[1], + ); + + expect(next.sessionErrors[sessionId]).toBeUndefined(); + }); + + test("new turn clears previous session error", () => { + const sessionId = "pi:session-1"; + const next = reducer(baseState({ sessionErrors: { [sessionId]: "old error" } }), { + type: "TURN_RUN_STARTED", + payload: { + id: "turn-1", + sessionID: sessionId, + startedAt: 1, + status: "running", + }, + } as Parameters[1]); + + expect(next.sessionErrors[sessionId]).toBeUndefined(); + }); + test("preserves chat-infra connection kind across backend status updates", () => { const next = reducer( baseState({ diff --git a/src/hooks/agent-reducer.ts b/src/hooks/agent-reducer.ts index d1f0c6c..75a5157 100644 --- a/src/hooks/agent-reducer.ts +++ b/src/hooks/agent-reducer.ts @@ -206,6 +206,7 @@ export type Action = }; } | { type: "SET_ERROR"; payload: string | null } + | { type: "SESSION_ERROR"; payload: { sessionID?: string; error: string } } | { type: "SET_BOOT_STATE"; payload: { @@ -922,8 +923,10 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen const run = action.payload; const busySessionIds = new Set(state.busySessionIds); busySessionIds.add(run.sessionID); + const { [run.sessionID]: _clearedSessionError, ...sessionErrors } = state.sessionErrors; return { ...state, + sessionErrors, busySessionIds, ...(run.sessionID === state.activeSessionId ? { isBusy: true } : {}), turnRuns: { @@ -938,7 +941,48 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen } case "SET_ERROR": - return { ...state, lastError: action.payload }; + return { + ...state, + lastError: action.payload, + ...(action.payload === null ? { sessionErrors: {} } : {}), + }; + + case "SESSION_ERROR": { + const { sessionID, error } = action.payload; + if (!sessionID) return { ...state, lastError: error }; + + const newBusy = new Set(state.busySessionIds); + newBusy.delete(sessionID); + const activeTurnId = getTurnRunIdForSession(state, sessionID); + const activeTurn = activeTurnId ? state.turnRuns[activeTurnId] : undefined; + const nextTurnRuns = + activeTurn?.status === "running" + ? { + ...state.turnRuns, + [activeTurn.id]: { + ...activeTurn, + completedAt: Date.now(), + status: "error" as const, + }, + } + : state.turnRuns; + const nextActiveTurnRunBySession = Object.fromEntries( + Object.entries(state.activeTurnRunBySession).filter(([sid, turnId]) => { + if (sid === sessionID) return false; + return turnId !== activeTurnId; + }), + ); + + return { + ...state, + lastError: error, + sessionErrors: { ...state.sessionErrors, [sessionID]: error }, + busySessionIds: newBusy, + turnRuns: nextTurnRuns, + activeTurnRunBySession: nextActiveTurnRunBySession, + ...(sessionID === state.activeSessionId ? { isBusy: false } : {}), + }; + } case "SET_PERMISSION": { const p = action.payload; @@ -1666,13 +1710,27 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen case "SESSION_STATUS": { const { sessionID, status } = action.payload; - const isBusy = status.type === "busy"; + const retryMessage = + status.type === "retry" && + "message" in status && + typeof status.message === "string" && + status.message.trim() + ? status.message.trim() + : null; + const isBusy = status.type === "busy" || status.type === "retry"; const newBusy = new Set(state.busySessionIds); if (isBusy) { newBusy.add(sessionID); } else { newBusy.delete(sessionID); } + const nextSessionErrors = retryMessage + ? { ...state.sessionErrors, [sessionID]: retryMessage } + : status.type === "idle" && state.sessionErrors[sessionID] + ? Object.fromEntries( + Object.entries(state.sessionErrors).filter(([id]) => id !== sessionID), + ) + : state.sessionErrors; // Keep session buffer cached even when session goes idle so // switching back to it is instant (LRU eviction handles cleanup). const nextBuffers = state._sessionBuffers; @@ -1705,6 +1763,8 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen ...state, ...completedTurnPatch, busySessionIds: newBusy, + sessionErrors: nextSessionErrors, + ...(retryMessage ? { lastError: retryMessage } : {}), unreadSessionIds: nextUnread, _sessionBuffers: nextBuffers, ...(sessionID === state.activeSessionId && !isBusy diff --git a/src/hooks/agent-state-types.ts b/src/hooks/agent-state-types.ts index 3dbb162..657a3b8 100644 --- a/src/hooks/agent-state-types.ts +++ b/src/hooks/agent-state-types.ts @@ -104,6 +104,8 @@ export interface InternalAgentState { pendingQuestions: Record; /** Last error surfaced to UI */ lastError: string | null; + /** Last error per session, shown next to active chat input */ + sessionErrors: Record; /** App startup status for local server bootstrap */ bootState: "idle" | "checking-server" | "starting-server" | "ready" | "error"; /** Startup error shown only when bootstrap fails */ diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index 9e074c4..a63aa0d 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2347,6 +2347,7 @@ function InternalAgentProvider({ unreadSessionIds: state.unreadSessionIds, sessionDrafts: state.sessionDrafts, sessionMeta: state.sessionMeta, + sessionErrors: state.sessionErrors, childSessions: state.childSessions, }), [ @@ -2365,6 +2366,7 @@ function InternalAgentProvider({ state.unreadSessionIds, state.sessionDrafts, state.sessionMeta, + state.sessionErrors, state.childSessions, ], ); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b987e94..d768e5d 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -392,6 +392,11 @@ "dismiss": "Ausblenden", "open": "Öffnen" }, + "sessionError": { + "title": "Agentenfehler", + "nextStep": "Nächster Schritt: Führe diesen Befehl im Terminal aus und versuche es erneut:", + "openTerminal": "Terminal öffnen" + }, "emptyStates": { "noProjectTitle": "Kein Projekt verbunden", "noProjectCanStart": "Verbinde jetzt ein Projekt oder starte einen Chat.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 162b67d..bfbee0d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -389,6 +389,11 @@ "dismiss": "Dismiss", "open": "Open" }, + "sessionError": { + "title": "Agent error", + "nextStep": "Next step: run this command in your terminal, then retry:", + "openTerminal": "Open terminal" + }, "emptyStates": { "noProjectTitle": "No project connected", "noProjectCanStart": "Connect a project now or start a chat.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e8dc78e..2f6f3ba 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -392,6 +392,11 @@ "dismiss": "Descartar", "open": "Abrir" }, + "sessionError": { + "title": "Error del agente", + "nextStep": "Siguiente paso: ejecuta este comando en tu terminal y vuelve a intentarlo:", + "openTerminal": "Abrir terminal" + }, "emptyStates": { "noProjectTitle": "No hay ningún proyecto conectado", "noProjectCanStart": "Conecta un proyecto ahora o inicia un chat.", From 771049ea55e0b6de8da2eba300b6b96dee201aad Mon Sep 17 00:00:00 2001 From: touch2be Date: Mon, 15 Jun 2026 00:02:33 +0200 Subject: [PATCH 2/5] Fix routed session revert actions --- server/services/harness-service.ts | 11 +++- server/services/session-lifecycle-actions.ts | 16 +++++- server/web-server.ts | 3 +- src/agents/backend.ts | 11 +++- src/hooks/agent-session-lifecycle.ts | 16 ++++-- src/hooks/use-agent-impl-core.tsx | 30 +++++++++- src/protocol/http-client.test.ts | 60 ++++++++++++++++++++ src/protocol/http-client.ts | 49 +++++++++------- 8 files changed, 163 insertions(+), 33 deletions(-) diff --git a/server/services/harness-service.ts b/server/services/harness-service.ts index 2dc2a43..2c916bb 100644 --- a/server/services/harness-service.ts +++ b/server/services/harness-service.ts @@ -346,6 +346,7 @@ export class HarnessService { async revertSession(input: { session: SessionRecord; + scope: HarnessScope; messageId: string; partId?: string; }): Promise { @@ -353,11 +354,17 @@ export class HarnessService { input.session.rawId, input.messageId, input.partId, + input.scope.directory, + undefined, ]); } - async unrevertSession(input: { session: SessionRecord }): Promise { - return this.backendRpc(input.session.harnessId, "session:unrevert", [input.session.rawId]); + async unrevertSession(input: { session: SessionRecord; scope: HarnessScope }): Promise { + return this.backendRpc(input.session.harnessId, "session:unrevert", [ + input.session.rawId, + input.scope.directory, + undefined, + ]); } async loadResources(input: { diff --git a/server/services/session-lifecycle-actions.ts b/server/services/session-lifecycle-actions.ts index 8e6d589..58eb22a 100644 --- a/server/services/session-lifecycle-actions.ts +++ b/server/services/session-lifecycle-actions.ts @@ -93,12 +93,18 @@ export async function forkSessionThroughHarness(input: { export async function revertSessionThroughHarness(input: { services: BackendServiceContext; + project: ProjectRecord; session: SessionRecord; messageId: string; partId?: string; }): Promise { const runtimeSession = await input.services.harnesses.revertSession({ session: input.session, + scope: buildHarnessScope({ + project: input.project, + harnessId: input.session.harnessId, + sessionId: input.session.id, + }), messageId: input.messageId, partId: input.partId, }); @@ -115,9 +121,17 @@ export async function revertSessionThroughHarness(input: { export async function unrevertSessionThroughHarness(input: { services: BackendServiceContext; + project: ProjectRecord; session: SessionRecord; }): Promise { - const runtimeSession = await input.services.harnesses.unrevertSession({ session: input.session }); + const runtimeSession = await input.services.harnesses.unrevertSession({ + session: input.session, + scope: buildHarnessScope({ + project: input.project, + harnessId: input.session.harnessId, + sessionId: input.session.id, + }), + }); if (!runtimeSession || typeof runtimeSession !== "object" || Array.isArray(runtimeSession)) { return null; } diff --git a/server/web-server.ts b/server/web-server.ts index 414c77b..b5da031 100644 --- a/server/web-server.ts +++ b/server/web-server.ts @@ -1323,6 +1323,7 @@ async function handleSessionRequest(request: Request) { } const session = await revertSessionThroughHarness({ services, + project, session: existing, messageId: body.messageId, partId: toOptionalString(body.partId, "partId"), @@ -1333,7 +1334,7 @@ async function handleSessionRequest(request: Request) { if (child === "unrevert") { if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); - const session = await unrevertSessionThroughHarness({ services, session: existing }); + const session = await unrevertSessionThroughHarness({ services, project, session: existing }); if (session) return Response.json({ ok: true, value: session }); return Response.json({ ok: true, value: true }); } diff --git a/src/agents/backend.ts b/src/agents/backend.ts index 4e6fd3b..76fa701 100644 --- a/src/agents/backend.ts +++ b/src/agents/backend.ts @@ -143,9 +143,14 @@ interface HarnessRuntime { deleteSession(sessionId: string): Promise; renameSession(sessionId: string, title: string): Promise; compactSession(sessionId: string, model?: SelectedModel, target?: HarnessTarget): Promise; - forkSession(sessionId: string, messageID?: string): Promise; - revertSession(sessionId: string, messageID: string, partID?: string): Promise; - unrevertSession(sessionId: string): Promise; + forkSession(sessionId: string, messageID?: string, target?: HarnessTarget): Promise; + revertSession( + sessionId: string, + messageID: string, + partID?: string, + target?: HarnessTarget, + ): Promise; + unrevertSession(sessionId: string, target?: HarnessTarget): Promise; sendCommand(input: { sessionId: string; command: string; diff --git a/src/hooks/agent-session-lifecycle.ts b/src/hooks/agent-session-lifecycle.ts index 20cba15..acf7bf5 100644 --- a/src/hooks/agent-session-lifecycle.ts +++ b/src/hooks/agent-session-lifecycle.ts @@ -1,4 +1,5 @@ import type { HarnessId } from "@/agents"; +import type { HarnessTarget } from "@/agents/backend"; import type { SessionMeta, WorktreeParentMap } from "@/hooks/agent-state-persistence"; import { resolvePendingPromptCreationHarnessRoute } from "@/hooks/agent-harness-routing"; import { getSessionHarnessId, getSessionProjectTarget } from "@/hooks/agent-session-utils"; @@ -65,9 +66,14 @@ interface SessionsClient { } interface SessionRuntime { - forkSession(sessionId: string, messageID?: string): Promise; - revertSession(sessionId: string, messageID: string): Promise; - unrevertSession(sessionId: string): Promise; + forkSession(sessionId: string, messageID?: string, target?: HarnessTarget): Promise; + revertSession( + sessionId: string, + messageID: string, + partID?: string, + target?: HarnessTarget, + ): Promise; + unrevertSession(sessionId: string, target?: HarnessTarget): Promise; } interface SessionMutationResult { @@ -368,6 +374,7 @@ export async function forkLifecycleSession({ selectSession, forceSessionTitle, dispatch, + target, }: { messageId: string; activeSessionId: string | null; @@ -376,12 +383,13 @@ export async function forkLifecycleSession({ selectSession: (sessionId: string, options?: { session?: Session }) => Promise; forceSessionTitle: (sessionId: string, title: string) => void; dispatch: (action: LifecycleAction) => void; + target?: HarnessTarget; }) { const plan = createSessionForkPlan({ activeSessionId, sessions }); if (!plan) return; try { - const session = await runtime.forkSession(plan.sourceSessionId, messageId); + const session = await runtime.forkSession(plan.sourceSessionId, messageId, target); const titledSession = { ...session, title: plan.forkTitle }; dispatch({ type: "SESSION_CREATED", payload: titledSession }); forceSessionTitle(session.id, plan.forkTitle); diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index a63aa0d..a2b9ceb 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2029,9 +2029,18 @@ function InternalAgentProvider({ ) ?? undefined, }); } + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await refreshLifecycleSession({ sessionId: state.activeSessionId, - mutateSession: () => runtime.revertSession(state.activeSessionId!, messageID), + mutateSession: () => + runtime.revertSession(state.activeSessionId!, messageID, undefined, projectTarget), fetchMessagePage, dispatch, errorMessage: "Failed to revert session", @@ -2049,9 +2058,17 @@ function InternalAgentProvider({ const unrevert = useCallback(async () => { if (!runtime || !state.activeSessionId) return; + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await refreshLifecycleSession({ sessionId: state.activeSessionId, - mutateSession: () => runtime.unrevertSession(state.activeSessionId!), + mutateSession: () => runtime.unrevertSession(state.activeSessionId!, projectTarget), fetchMessagePage, dispatch, errorMessage: "Failed to unrevert session", @@ -2061,6 +2078,14 @@ function InternalAgentProvider({ const forkFromMessage = useCallback( async (messageID: string) => { if (!runtime || !state.activeSessionId) return; + const activeSession = stateRef.current.sessions.find( + (session) => session.id === state.activeSessionId, + ); + const projectTarget = + getSessionProjectTarget( + activeSession, + activeSession ? stateRef.current.sessionMeta[activeSession.id] : undefined, + ) ?? undefined; await forkLifecycleSession({ messageId: messageID, activeSessionId: state.activeSessionId, @@ -2069,6 +2094,7 @@ function InternalAgentProvider({ selectSession, forceSessionTitle, dispatch, + target: projectTarget, }); }, [runtime, state.activeSessionId, selectSession, forceSessionTitle], diff --git a/src/protocol/http-client.test.ts b/src/protocol/http-client.test.ts index 5d5eb59..dbf121e 100644 --- a/src/protocol/http-client.test.ts +++ b/src/protocol/http-client.test.ts @@ -303,6 +303,66 @@ describe("createHttpOpenGuiClient", () => { }); }); + test("routes runtime revert through session project target", async () => { + const calls: Array<{ url: string; method: string; body: unknown }> = []; + const sessionRecord = { + id: "session_1", + rawId: "native-1", + projectId: "project_1", + harnessId: "opencode", + title: "Chat", + status: "unknown", + createdAt: "2026-05-12T00:00:00.000Z", + updatedAt: "2026-05-12T00:00:00.000Z", + }; + const projectRecord = { + id: "project_1", + displayName: "repo", + path: "/repo", + canonicalPath: "/repo", + createdAt: "2026-05-12T00:00:00.000Z", + updatedAt: "2026-05-12T00:00:00.000Z", + }; + const client = createHttpOpenGuiClient({ + baseUrl: "http://example.test", + fetchImpl: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + if (url.endsWith("/api/projects") && method === "GET") { + return json({ ok: true, value: [projectRecord] }); + } + if (url.endsWith("/api/projects/project_1") && method === "GET") { + return json({ ok: true, value: projectRecord }); + } + if ( + url === + "http://example.test/api/sessions/opencode%3Asession_1/revert?harnessId=opencode&projectId=project_1" && + method === "POST" + ) { + return json({ ok: true, value: sessionRecord }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }, + }); + + const session = await client.harnesses + .get("opencode") + ?.runtime.revertSession("opencode:session_1", "msg_1", undefined, { + directory: "/repo", + workspaceId: "workspace-1", + }); + + expect(session).toMatchObject({ id: "opencode:native-1", _projectDir: "/repo" }); + expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ + "GET http://example.test/api/projects", + "POST http://example.test/api/sessions/opencode%3Asession_1/revert?harnessId=opencode&projectId=project_1", + "GET http://example.test/api/projects/project_1", + ]); + expect(calls[1]?.body).toEqual({ messageId: "msg_1" }); + }); + test("sends session and project context with question replies", async () => { const calls: Array<{ url: string; method: string; body: unknown }> = []; const client = createHttpOpenGuiClient({ diff --git a/src/protocol/http-client.ts b/src/protocol/http-client.ts index 071caaa..b70909f 100644 --- a/src/protocol/http-client.ts +++ b/src/protocol/http-client.ts @@ -150,10 +150,12 @@ function createWebBackendDescriptor( renameSession: (sessionId, title) => backendCall("session:update", [sessionId, title]), compactSession: (sessionId, model, target) => backendCall("session:summarize", [sessionId, model, ...targetArgs(target)]), - forkSession: (sessionId, messageID) => backendCall("session:fork", [sessionId, messageID]), - revertSession: (sessionId, messageID, partID) => - backendCall("session:revert", [sessionId, messageID, partID]), - unrevertSession: (sessionId) => backendCall("session:unrevert", [sessionId]), + forkSession: (sessionId, messageID, target) => + backendCall("session:fork", [sessionId, messageID, ...targetArgs(target)]), + revertSession: (sessionId, messageID, partID, target) => + backendCall("session:revert", [sessionId, messageID, partID, ...targetArgs(target)]), + unrevertSession: (sessionId, target) => + backendCall("session:unrevert", [sessionId, ...targetArgs(target)]), sendCommand: (input) => backendCall("command:send", [ input.sessionId, @@ -578,15 +580,20 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession(record); }, - compactSession: async (sessionId, model) => { - await request(await resolveSessionPath({ sessionId, harnessId }, "/compact"), { - method: "POST", - body: JSON.stringify({ model }), - }); + compactSession: async (sessionId, model, target) => { + await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/compact"), + { + method: "POST", + body: JSON.stringify({ model }), + }, + ); }, - forkSession: async (sessionId, messageID) => { - const record = await request( - await resolveSessionPath({ sessionId, harnessId }, "/fork"), + forkSession: async (sessionId, messageID, target) => { + const record = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/fork"), { method: "POST", body: JSON.stringify({ messageId: messageID }), @@ -594,9 +601,10 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession(record); }, - revertSession: async (sessionId, messageID, partID) => { - const value = await request( - await resolveSessionPath({ sessionId, harnessId }, "/revert"), + revertSession: async (sessionId, messageID, partID, target) => { + const value = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/revert"), { method: "POST", body: JSON.stringify({ messageId: messageID, partId: partID }), @@ -604,18 +612,19 @@ export function createHttpOpenGuiClient(options: HttpOpenGuiClientOptions = {}): ); return await toFrontendSession( typeof value === "boolean" - ? await getSessionRecord(sessionId, { sessionId, harnessId }) + ? await getSessionRecord(sessionId, { sessionId, harnessId, target }) : value, ); }, - unrevertSession: async (sessionId) => { - const value = await request( - await resolveSessionPath({ sessionId, harnessId }, "/unrevert"), + unrevertSession: async (sessionId, target) => { + const value = await requestAt( + requestBaseUrlForSession({ sessionId, harnessId, target }), + await resolveSessionPath({ sessionId, harnessId, target }, "/unrevert"), { method: "POST" }, ); return await toFrontendSession( typeof value === "boolean" - ? await getSessionRecord(sessionId, { sessionId, harnessId }) + ? await getSessionRecord(sessionId, { sessionId, harnessId, target }) : value, ); }, From c7c3ca84606f7ab4a9376ec67b1d17480cec6235 Mon Sep 17 00:00:00 2001 From: touch2be Date: Mon, 15 Jun 2026 16:16:31 +0200 Subject: [PATCH 3/5] fix: queue prompts for sessions without project records --- server/services/prompt-queue-service.ts | 23 +++-- src/hooks/agent-local-intent.test.ts | 53 ++++++++++++ src/hooks/agent-local-intent.ts | 35 +++++--- src/hooks/agent-session-queue.ts | 9 +- src/hooks/use-agent-impl-core.tsx | 108 ++++++++++++++++++++++++ src/hooks/use-prompt-submit.ts | 83 +++++++++++------- src/protocol/client.ts | 2 +- src/server-prompt-queue-service.test.ts | 72 ++++++++++++++++ 8 files changed, 332 insertions(+), 53 deletions(-) create mode 100644 src/server-prompt-queue-service.test.ts diff --git a/server/services/prompt-queue-service.ts b/server/services/prompt-queue-service.ts index 3b5b17c..73e5544 100644 --- a/server/services/prompt-queue-service.ts +++ b/server/services/prompt-queue-service.ts @@ -90,7 +90,7 @@ export class PromptQueueService { const created = await this.storage.createPromptQueueEntry({ sessionId: session.id, harnessId: session.harnessId, - projectDirectory: await this.getProjectDirectory(session.projectId), + projectDirectory: await this.getProjectDirectory(session), harnessSessionId: session.rawId, text: input.text, model: input.model, @@ -271,10 +271,23 @@ export class PromptQueueService { return sessions; } - private async getProjectDirectory(projectId: string): Promise { - const project = await this.projects.getProject(projectId); - if (!project) throw new Error("Project not found"); - return project.canonicalPath || project.path; + private async getProjectDirectory(session: SessionRecord): Promise { + const project = await this.projects.getProject(session.projectId); + if (project) return project.canonicalPath || project.path; + + const metadataDirectory = + session.metadata && typeof session.metadata.directory === "string" + ? session.metadata.directory.trim() + : ""; + if (metadataDirectory) return metadataDirectory; + + // Older Session records sometimes used the directory itself as projectId. + // Keep queue usable for those records instead of failing a running Session. + if (session.projectId.startsWith("/") || session.projectId.startsWith("~")) { + return session.projectId; + } + + throw new Error("Project not found"); } private async reindex(sessionId: string): Promise { diff --git a/src/hooks/agent-local-intent.test.ts b/src/hooks/agent-local-intent.test.ts index 28e5f5b..874ee45 100644 --- a/src/hooks/agent-local-intent.test.ts +++ b/src/hooks/agent-local-intent.test.ts @@ -302,6 +302,59 @@ describe("createLocalIntentOrchestrator", () => { ]); }); + test("queues busy-session prompts even when local Session lacks project directory metadata", async () => { + const session = makeSession({ + id: "pi:session-1", + directory: undefined as never, + _harnessId: "pi", + _rawId: "session-1", + _workspaceId: "workspace-1", + }); + const model: SelectedModel = { providerID: "openai", modelID: "gpt-5" }; + const state = makeState({ + activeSessionId: session.id, + sessions: [session], + busySessionIds: new Set([session.id]), + selectedModel: model, + }); + const queued: Array> = []; + + const orchestrator = createLocalIntentOrchestrator({ + getState: () => state, + getResourceRuntime: () => undefined, + getCurrentVariant: () => undefined, + sessionsClient: { + prompt: async () => undefined, + abort: async () => undefined, + queue: { + enqueue: async (input: Record) => { + queued.push(input); + return [{ id: "queue-1", text: String(input.text), mode: "queue" }]; + }, + }, + } as never, + createSession: async () => null, + scheduleSessionMessageReconcile: () => undefined, + requestSessionAutoName: () => undefined, + dispatch: () => undefined, + sessionCreatingRef: { current: false }, + }); + + await orchestrator.sendPrompt("Queue without directory"); + + expect(queued).toEqual([ + expect.objectContaining({ + sessionId: "pi:session-1", + text: "Queue without directory", + model, + mode: "queue", + insertAt: "back", + harnessId: "pi", + target: { workspaceId: "workspace-1" }, + }), + ]); + }); + test("sends commands through the active backend runtime and reconciles afterward", async () => { const session = makeSession({ id: "session-1", diff --git a/src/hooks/agent-local-intent.ts b/src/hooks/agent-local-intent.ts index bfe8650..541b942 100644 --- a/src/hooks/agent-local-intent.ts +++ b/src/hooks/agent-local-intent.ts @@ -18,6 +18,7 @@ import { import { decideSessionEntry } from "@/hooks/agent-session-entry"; import { getSessionProjectTarget } from "@/hooks/agent-session-utils"; import type { InternalAgentState, QueueMode, Session } from "@/hooks/agent-state-types"; +import { getErrorMessage } from "@/lib/utils"; import { getSessionDraftKey } from "@/lib/session-drafts"; import { generateSessionTitle } from "@/lib/session-namer"; import type { OpenGuiClient } from "@/protocol/client"; @@ -207,8 +208,9 @@ export function createLocalIntentOrchestrator( mode, }); scheduleSessionMessageReconcile(sessionId, projectTarget); - } catch { + } catch (error) { dispatch({ type: "SET_BUSY", payload: false }); + dispatch({ type: "SET_ERROR", payload: getErrorMessage(error, "Failed to send prompt") }); } }; @@ -239,16 +241,24 @@ export function createLocalIntentOrchestrator( dispatch({ type: "SET_ERROR", payload: "Choose a Harness model before sending." }); return false; } - await sessionQueue.enqueuePrompt({ - sessionId: input.sessionId, - text: prepareDirectoryChangePrompt(input.sessionId, input.text), - model: selection.model, - agent: selection.agent, - variant: selection.variant, - mode: input.mode, - insertAt: input.insertAt, - }); - return true; + try { + await sessionQueue.enqueuePrompt({ + sessionId: input.sessionId, + text: prepareDirectoryChangePrompt(input.sessionId, input.text), + model: selection.model, + agent: selection.agent, + variant: selection.variant, + mode: input.mode, + insertAt: input.insertAt, + }); + return true; + } catch (error) { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error, "Failed to queue prompt"), + }); + return false; + } }; const resolveNamedSession = async (sourceText: string): Promise => { @@ -347,8 +357,9 @@ export function createLocalIntentOrchestrator( }, }); scheduleSessionMessageReconcile(sessionId, projectTarget); - } catch { + } catch (error) { dispatch({ type: "SET_BUSY", payload: false }); + dispatch({ type: "SET_ERROR", payload: getErrorMessage(error, "Failed to send command") }); } }; diff --git a/src/hooks/agent-session-queue.ts b/src/hooks/agent-session-queue.ts index a3d2b26..8115d43 100644 --- a/src/hooks/agent-session-queue.ts +++ b/src/hooks/agent-session-queue.ts @@ -1,5 +1,5 @@ import { resolveSessionHarnessRoute } from "@/hooks/agent-harness-routing"; -import { getSessionProjectTarget } from "@/hooks/agent-session-utils"; +import { getSessionProjectTarget, getSessionWorkspaceId } from "@/hooks/agent-session-utils"; import type { SessionMetaMap } from "@/hooks/agent-state-persistence"; import type { InternalAgentState, QueuedPrompt, Session } from "@/hooks/agent-state-types"; import type { OpenGuiClient, QueueScopeInput } from "@/protocol/client"; @@ -33,10 +33,11 @@ function getSessionLookup( const harnessId = resolveSessionHarnessRoute(session).harnessId ?? undefined; const target = getSessionProjectTarget(session, session ? sessionMeta[session.id] : undefined) ?? undefined; - if (!harnessId || !target?.directory) { - throw new Error("Queued prompt target requires Harness, Project directory, and Session ID"); + const workspaceId = getSessionWorkspaceId(session) ?? undefined; + if (!harnessId) { + throw new Error("Queued prompt target requires Harness and Session ID"); } - return { harnessId, target: { ...target, directory: target.directory } }; + return { harnessId, target: target ?? (workspaceId ? { workspaceId } : undefined) }; } export function createSessionQueueOrchestrator( diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index a2b9ceb..3a26811 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2003,6 +2003,114 @@ function InternalAgentProvider({ } }, [state.bootState]); + const getQueuedPrompts = useCallback( + (sessionId: string) => state.queuedPrompts[sessionId] ?? [], + [state.queuedPrompts], + ); + + const applyQueueSnapshot = useCallback( + (sessionId: string, prompts: (typeof state.queuedPrompts)[string]) => { + dispatch({ type: "SET_SESSION_QUEUE", payload: { sessionID: sessionId, prompts } }); + }, + [], + ); + + const getQueueTarget = useCallback((sessionId: string) => { + const session = stateRef.current.sessions.find((item) => item.id === sessionId); + const harnessId = resolveSessionHarnessRoute(session).harnessId ?? undefined; + const target = + getSessionProjectTarget( + session, + session ? stateRef.current.sessionMeta[session.id] : undefined, + ) ?? undefined; + const workspaceId = getSessionWorkspaceId(session) ?? undefined; + if (!harnessId) { + throw new Error("Queued prompt target requires Harness and Session ID"); + } + return { + harnessId, + target: target ?? (workspaceId ? { workspaceId } : undefined), + }; + }, []); + + const removeFromQueue = useCallback( + (sessionId: string, promptId: string) => { + let queueTarget: ReturnType; + try { + queueTarget = getQueueTarget(sessionId); + } catch (error) { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to resolve queued prompt target", + }); + return; + } + void openGuiClient.sessions.queue + .remove({ sessionId, entryId: promptId, ...queueTarget }) + .then((prompts) => applyQueueSnapshot(sessionId, prompts)) + .catch((error) => { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to remove queued prompt", + }); + }); + }, + [applyQueueSnapshot, getQueueTarget, openGuiClient], + ); + + const reorderQueue = useCallback( + (sessionId: string, fromIndex: number, toIndex: number) => { + const existing = stateRef.current.queuedPrompts[sessionId] ?? []; + const entryId = existing[fromIndex]?.id; + if (!entryId) return; + let queueTarget: ReturnType; + try { + queueTarget = getQueueTarget(sessionId); + } catch (error) { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to resolve queued prompt target", + }); + return; + } + void openGuiClient.sessions.queue + .reorder({ sessionId, entryId, index: toIndex, ...queueTarget }) + .then((prompts) => applyQueueSnapshot(sessionId, prompts)) + .catch((error) => { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to reorder queue", + }); + }); + }, + [applyQueueSnapshot, getQueueTarget, openGuiClient], + ); + + const updateQueuedPrompt = useCallback( + (sessionId: string, promptId: string, text: string) => { + let queueTarget: ReturnType; + try { + queueTarget = getQueueTarget(sessionId); + } catch (error) { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to resolve queued prompt target", + }); + return; + } + void openGuiClient.sessions.queue + .update({ sessionId, entryId: promptId, text, ...queueTarget }) + .then((prompts) => applyQueueSnapshot(sessionId, prompts)) + .catch((error) => { + dispatch({ + type: "SET_ERROR", + payload: getErrorMessage(error) || "Failed to update queued prompt", + }); + }); + }, + [applyQueueSnapshot, getQueueTarget, openGuiClient], + ); + const setSessionDraft = useCallback((key: string, text: string) => { dispatch({ type: "SET_SESSION_DRAFT", payload: { key, text } }); }, []); diff --git a/src/hooks/use-prompt-submit.ts b/src/hooks/use-prompt-submit.ts index 969e850..ad9aaeb 100644 --- a/src/hooks/use-prompt-submit.ts +++ b/src/hooks/use-prompt-submit.ts @@ -68,21 +68,53 @@ export function usePromptSubmit({ resetHistory: () => void; }) { const submittingRef = React.useRef(false); + const latestRef = React.useRef({ + value, + disabled, + isUploading, + isLoading, + queueMode, + parseSlashCommand, + sendCommand, + onSubmit, + clearPromptDraft, + onAfterSubmit, + resetSlashCommand, + resetHistory, + }); + + latestRef.current = { + value, + disabled, + isUploading, + isLoading, + queueMode, + parseSlashCommand, + sendCommand, + onSubmit, + clearPromptDraft, + onAfterSubmit, + resetSlashCommand, + resetHistory, + }; + const hasValue = value.trim().length > 0; const submit = React.useCallback(async () => { + const latest = latestRef.current; + const currentValue = latest.value; if (submittingRef.current) return; - if (disabled || isUploading) return; - if (!hasValue) return; + if (latest.disabled || latest.isUploading) return; + if (currentValue.trim().length === 0) return; submittingRef.current = true; const decision = decidePromptSubmit({ - value, - disabled, - isUploading, - isLoading, - queueMode, - slashInvocation: parseSlashCommand(value), + value: currentValue, + disabled: latest.disabled, + isUploading: latest.isUploading, + isLoading: latest.isLoading, + queueMode: latest.queueMode, + slashInvocation: latest.parseSlashCommand(currentValue), }); if (decision.type === "skip") { @@ -92,35 +124,24 @@ export function usePromptSubmit({ try { if (decision.type === "command") { - clearPromptDraft(); - resetSlashCommand(); - resetHistory(); - await sendCommand(decision.commandName, decision.args); + latest.clearPromptDraft(); + latest.resetSlashCommand(); + latest.resetHistory(); + await latest.sendCommand(decision.commandName, decision.args); return; } - await onSubmit?.(decision.text, decision.mode); - clearPromptDraft(); - onAfterSubmit?.(); - resetHistory(); + const submission = Promise.resolve(latest.onSubmit?.(decision.text, decision.mode)); + latest.clearPromptDraft(); + latest.onAfterSubmit?.(); + latest.resetHistory(); + submission.catch((error) => { + console.error("Failed to submit prompt", error); + }); } finally { submittingRef.current = false; } - }, [ - clearPromptDraft, - disabled, - hasValue, - isUploading, - isLoading, - onSubmit, - onAfterSubmit, - parseSlashCommand, - queueMode, - resetHistory, - resetSlashCommand, - sendCommand, - value, - ]); + }, []); return { hasValue, submit }; } diff --git a/src/protocol/client.ts b/src/protocol/client.ts index 74625ed..d6056ed 100644 --- a/src/protocol/client.ts +++ b/src/protocol/client.ts @@ -139,7 +139,7 @@ export type QueueHarnessTarget = HarnessTarget & { directory: string }; export interface QueueScopeInput { harnessId: HarnessId; - target: QueueHarnessTarget; + target?: HarnessTarget; } export interface OpenGuiClient { diff --git a/src/server-prompt-queue-service.test.ts b/src/server-prompt-queue-service.test.ts new file mode 100644 index 0000000..71fb702 --- /dev/null +++ b/src/server-prompt-queue-service.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import { PromptQueueService } from "../server/services/prompt-queue-service.ts"; +import type { PromptQueueEntryRecord, StorageService } from "../server/services/storage-service.ts"; +import type { SessionRecord } from "../server/services/session-types.ts"; + +function makeStorage(entries: PromptQueueEntryRecord[] = []): StorageService { + return { + listPromptQueue: async () => entries, + createPromptQueueEntry: async (input) => { + const entry: PromptQueueEntryRecord = { + id: input.id ?? "queue-1", + sessionId: input.sessionId, + harnessId: input.harnessId, + projectDirectory: input.projectDirectory, + harnessSessionId: input.harnessSessionId, + text: input.text, + createdAt: input.createdAt ?? 1, + model: input.model, + agent: input.agent, + variant: input.variant, + mode: input.mode, + order: input.order ?? entries.length, + }; + entries.push(entry); + return entry; + }, + replacePromptQueue: async (_sessionId, next) => { + entries.splice(0, entries.length, ...next); + return entries; + }, + } as StorageService; +} + +describe("PromptQueueService", () => { + test("uses Session metadata directory when Project record is missing", async () => { + const session: SessionRecord = { + id: "session_1", + rawId: "raw-1", + projectId: "missing-project", + harnessId: "opencode", + title: "Running", + status: "running", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + metadata: { directory: "/repo" }, + }; + const queue = new PromptQueueService( + makeStorage(), + { + getSession: async () => session, + } as never, + { + getProject: async () => null, + } as never, + ); + + const entries = await queue.enqueue( + "opencode:raw-1", + { text: "Continue", mode: "queue" }, + { projectId: session.projectId, harnessId: session.harnessId }, + ); + + expect(entries).toEqual([ + expect.objectContaining({ + sessionId: "opencode:raw-1", + canonicalSessionId: "session_1", + projectDirectory: "/repo", + text: "Continue", + }), + ]); + }); +}); From 883174815f61c1489b2ac024b1c3c8fdf0a065f5 Mon Sep 17 00:00:00 2001 From: touch2be Date: Mon, 15 Jun 2026 19:59:48 +0200 Subject: [PATCH 4/5] Update renderer links and shell IPC handling --- .agents/skills/agent-browser/SKILL.md | 55 ---- main.ts | 51 ++++ preload.ts | 3 + server/shell-ipc-handlers.ts | 57 +++- skills-lock.json | 264 +++++++++++++++++++ src/components/FilePathToken.tsx | 94 +++++++ src/components/LinkToken.tsx | 31 +++ src/components/MarkdownRenderer.tsx | 131 +++++---- src/components/PromptAddMenu.tsx | 2 +- src/components/message-list/TextPartView.tsx | 2 +- src/i18n/locales/de.json | 7 + src/i18n/locales/en.json | 7 + src/i18n/locales/es.json | 7 + src/lib/file-paths.ts | 22 ++ src/lib/urls.ts | 9 + src/lib/web-electron-api.ts | 6 + src/types/electron.d.ts | 9 + 17 files changed, 649 insertions(+), 108 deletions(-) delete mode 100644 .agents/skills/agent-browser/SKILL.md create mode 100644 src/components/FilePathToken.tsx create mode 100644 src/components/LinkToken.tsx create mode 100644 src/lib/file-paths.ts create mode 100644 src/lib/urls.ts diff --git a/.agents/skills/agent-browser/SKILL.md b/.agents/skills/agent-browser/SKILL.md deleted file mode 100644 index cefd752..0000000 --- a/.agents/skills/agent-browser/SKILL.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: agent-browser -description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. -allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) -hidden: true ---- - -# agent-browser - -Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with -accessibility-tree snapshots and compact `@eN` element refs. - -Install: `npm i -g agent-browser && agent-browser install` - -## Start here - -This file is a discovery stub, not the usage guide. Before running any -`agent-browser` command, load the actual workflow content from the CLI: - -```bash -agent-browser skills get core # start here — workflows, common patterns, troubleshooting -agent-browser skills get core --full # include full command reference and templates -``` - -The CLI serves skill content that always matches the installed version, -so instructions never go stale. The content in this stub cannot change -between releases, which is why it just points at `skills get core`. - -## Specialized skills - -Load a specialized skill when the task falls outside browser web pages: - -```bash -agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) -agent-browser skills get slack # Slack workspace automation -agent-browser skills get dogfood # Exploratory testing / QA / bug hunts -agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs -agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers -``` - -Run `agent-browser skills list` to see everything available on the -installed version. - -## Why agent-browser - -- Fast native Rust CLI, not a Node.js wrapper -- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) -- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency -- Accessibility-tree snapshots with element refs for reliable interaction -- Sessions, authentication vault, state persistence, video recording -- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers - -## Observability Dashboard - -The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/main.ts b/main.ts index 2d86851..bace3f6 100644 --- a/main.ts +++ b/main.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const { app, BrowserWindow, dialog, ipcMain, shell, session } = require("electron") as typeof import("electron"); +import { existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { execSync, spawn } from "node:child_process"; import type { SpawnOptions } from "node:child_process"; @@ -62,6 +63,38 @@ function spawnCustomCommand(command: unknown, options: SpawnOptions = {}) { return true; } +function cleanOpenPath(value: unknown) { + if (typeof value !== "string") return ""; + return value.trim().replace(/^["']|["']$/g, ""); +} + +function isPathInside(candidate: string, parent: string) { + const relative = path.relative(parent, candidate); + return ( + relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) { + const target = cleanOpenPath(filePath); + if (!target || target.includes("\0")) return null; + + const base = cleanOpenPath(baseDirectory); + const normalizedBase = base ? path.resolve(base) : null; + if (!normalizedBase) return null; + + const isAbsolute = path.isAbsolute(target) || path.win32.isAbsolute(target); + const resolved = isAbsolute ? path.resolve(target) : path.resolve(normalizedBase, target); + if (normalizedBase && !isPathInside(resolved, normalizedBase)) return null; + + try { + if (!existsSync(resolved) || !statSync(resolved).isFile()) return null; + } catch { + return null; + } + return resolved; +} + function getDesktopTerminalCommand() { const gsettingsKeys = [ "org.cinnamon.desktop.default-applications.terminal exec", @@ -483,6 +516,24 @@ ipcMain.handle("shell:openExternal", (_event, url) => { if (isWebUrl(url)) void shell.openExternal(url); }); +ipcMain.handle("shell:fileExists", (_event, filePath, baseDirectory) => { + return !!resolveFileTarget(filePath, baseDirectory); +}); + +ipcMain.handle("shell:openFile", async (_event, filePath, baseDirectory) => { + const target = resolveFileTarget(filePath, baseDirectory); + if (!target) return false; + const error = await shell.openPath(target); + return !error; +}); + +ipcMain.handle("shell:showFileInFolder", (_event, filePath, baseDirectory) => { + const target = resolveFileTarget(filePath, baseDirectory); + if (!target) return false; + shell.showItemInFolder(target); + return true; +}); + // Open a directory in the system file browser ipcMain.handle("shell:openInFileBrowser", (_event, dirPath, command = "") => { if (typeof dirPath !== "string" || dirPath.length === 0) return; diff --git a/preload.ts b/preload.ts index 9301d8a..43b3158 100644 --- a/preload.ts +++ b/preload.ts @@ -113,6 +113,9 @@ const electronAPI: ElectronAPI = { }, openExternal: invoke("shell:openExternal"), + fileExists: invoke("shell:fileExists"), + openFile: invoke("shell:openFile"), + showFileInFolder: invoke("shell:showFileInFolder"), updates: { getState: async () => disabledUpdateState, check: async () => disabledUpdateState, diff --git a/server/shell-ipc-handlers.ts b/server/shell-ipc-handlers.ts index 9a22db1..5240d37 100644 --- a/server/shell-ipc-handlers.ts +++ b/server/shell-ipc-handlers.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve, win32 } from "node:path"; import { homedir } from "node:os"; import type { BackendServiceContext } from "./services/index.ts"; import { getHarnessInventories } from "./harness-inventory.ts"; @@ -54,6 +55,45 @@ function openPath(path: string) { else spawnDetached("xdg-open", [path]); } +function showFileInFolder(path: string) { + if (process.platform === "darwin") spawnDetached("open", ["-R", path]); + else if (process.platform === "win32") spawnDetached("explorer.exe", [`/select,${path}`]); + else openPath(dirname(path)); +} + +function cleanOpenPath(value: unknown) { + if (typeof value !== "string") return ""; + return value.trim().replace(/^["']|["']$/g, ""); +} + +function isPathInside(candidate: string, parent: string) { + const childRelative = relative(parent, candidate); + return ( + childRelative === "" || + (!!childRelative && !childRelative.startsWith("..") && !isAbsolute(childRelative)) + ); +} + +function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) { + const target = cleanOpenPath(filePath); + if (!target || target.includes("\0")) return null; + + const base = cleanOpenPath(baseDirectory); + const normalizedBase = base ? resolve(base) : null; + if (!normalizedBase) return null; + + const targetAbsolute = isAbsolute(target) || win32.isAbsolute(target); + const resolved = targetAbsolute ? resolve(target) : resolve(normalizedBase, target); + if (normalizedBase && !isPathInside(resolved, normalizedBase)) return null; + + try { + if (!existsSync(resolved) || !statSync(resolved).isFile()) return null; + } catch { + return null; + } + return resolved; +} + async function runPicker(command: string[]) { const [file, ...args] = command; if (!file) return null; @@ -189,6 +229,21 @@ export function registerShellIpcHandlers(input: { ipcMain.handle("shell:openExternal", (_event, url) => openExternal(typeof url === "string" ? url : ""), ); + ipcMain.handle("shell:fileExists", (_event, filePath, baseDirectory) => { + return !!resolveFileTarget(filePath, baseDirectory); + }); + ipcMain.handle("shell:openFile", (_event, filePath, baseDirectory) => { + const target = resolveFileTarget(filePath, baseDirectory); + if (!target) return false; + openPath(target); + return true; + }); + ipcMain.handle("shell:showFileInFolder", (_event, filePath, baseDirectory) => { + const target = resolveFileTarget(filePath, baseDirectory); + if (!target) return false; + showFileInFolder(target); + return true; + }); ipcMain.handle("shell:openInFileBrowser", (_event, dirPath, command = "") => { const dir = typeof dirPath === "string" ? dirPath : ""; if (!dir) return; diff --git a/skills-lock.json b/skills-lock.json index 6459f75..d7ebe7d 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,17 +1,281 @@ { "version": 1, "skills": { + "ab-testing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/ab-testing/SKILL.md", + "computedHash": "c42af0107540e71dfe9605eca51b5100d96730d0bddf51085fc0c9429e40083c" + }, + "ad-creative": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/ad-creative/SKILL.md", + "computedHash": "822b7c608a6546680bb1389f619bcd00556974b7bc1a060b217cec94afc22ae0" + }, + "ads": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/ads/SKILL.md", + "computedHash": "3f623f4dd628a2989bf9e181bb8100ddf8998cbacbbfa1dbcff69642fedd9da0" + }, "agent-browser": { "source": "vercel-labs/agent-browser", "sourceType": "github", "skillPath": "skills/agent-browser/SKILL.md", "computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8" }, + "ai-seo": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/ai-seo/SKILL.md", + "computedHash": "a6befed362ed7127d051db085bc8be9470abbe87f70a40ee9a05e201dd8c693f" + }, + "analytics": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/analytics/SKILL.md", + "computedHash": "11fcba681577e36b5ad88e391d932a9c94fb1e593e94010c750739eefa9c4057" + }, + "aso": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/aso/SKILL.md", + "computedHash": "f84a1fb5cb1a8a7f7171bd60ae6850f24a82fa6af94bd5f12e5b3e9227480a34" + }, + "churn-prevention": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/churn-prevention/SKILL.md", + "computedHash": "527c4740617d9eeea66e14c3f7b7ecede41d6b0520c252f413fdfd0b763a6c64" + }, + "co-marketing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/co-marketing/SKILL.md", + "computedHash": "1f447a4e851f9e6be533c41d198e0a1b58d557bcfe57499dace0c7f1349ba6cb" + }, + "cold-email": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/cold-email/SKILL.md", + "computedHash": "be0b73511a6244b8b8aa9126225844cfe9e492f4c1ee6bffc57344ce5ee2a6e8" + }, + "community-marketing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/community-marketing/SKILL.md", + "computedHash": "d089fdcff8cf5a8623cfaee0fdb005f9470c8c7e3da0a4fb11bc9e10dff5b502" + }, + "competitor-profiling": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/competitor-profiling/SKILL.md", + "computedHash": "d691200df7b02d85327192dd2203f97eec5a01e7748e9b171a5c9d6a584dc181" + }, + "competitors": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/competitors/SKILL.md", + "computedHash": "9e15de4d3b5e9a44f500124e2d2f57ba7fd3f4134912d9caf155ff6975215e99" + }, + "content-strategy": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/content-strategy/SKILL.md", + "computedHash": "13824c129ab70874c57d766c2d844f8dac7a0b366845f108a73527c9d094c4f6" + }, + "copy-editing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/copy-editing/SKILL.md", + "computedHash": "ba4e44a8470b3ae2add3e0ffa74c85d8b0357db4ca2cf8cfa7b4abc9d94fc602" + }, + "copywriting": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/copywriting/SKILL.md", + "computedHash": "d3191c3ad90610e0cacecf638d927418852264fabbd6e81c045094dad7d96f11" + }, + "cro": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/cro/SKILL.md", + "computedHash": "07f7a27c57e48f4b67230712f8efbc5bcc01611b35321b3c2c2e5279c9304bcd" + }, + "customer-research": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/customer-research/SKILL.md", + "computedHash": "cc68018a343c1a75ef9aed44b7037928795f654bdde0b28372176f686c1982be" + }, + "directory-submissions": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/directory-submissions/SKILL.md", + "computedHash": "b737c3b9b9387bbeefd4fb5a003c1debaf273563f170665f6b9341c6818d121b" + }, + "emails": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/emails/SKILL.md", + "computedHash": "261d986c2a1f1f5f823f8c889e16db723edb8a9d0fa5ede8412f981594715c49" + }, "find-skills": { "source": "vercel-labs/skills", "sourceType": "github", "skillPath": "skills/find-skills/SKILL.md", "computedHash": "9e1c8b3103f92fa8092568a44fe64858de7c5c9dc65ce4bea8f168080e889cfd" + }, + "free-tools": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/free-tools/SKILL.md", + "computedHash": "3fd8ab504b9b6d791b307baed7a87f49b455fc084ff367772c7418b8a57ce56d" + }, + "image": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/image/SKILL.md", + "computedHash": "e4b5511566d08a79f6f5fd26319eaaac0e18e1e238848c5a3fb2e9839e306ebe" + }, + "launch": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/launch/SKILL.md", + "computedHash": "3d8dabb81442f0e9f36359b103d95f254c56997d2cd3d2eeb80151419df7a07b" + }, + "lead-magnets": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/lead-magnets/SKILL.md", + "computedHash": "268c83c38cc49d3e437042f5d4e3c31b822deb6012b9d5d7a1370ae500058018" + }, + "marketing-ideas": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/marketing-ideas/SKILL.md", + "computedHash": "c2a310c97126a0c493bae2b4b0e9fa767598bb2d139a747217fa1d8d7615e85d" + }, + "marketing-plan": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/marketing-plan/SKILL.md", + "computedHash": "c6a0c9473c11a6b3c7ffe72bcf76417ce7e1a90330147fa1aac8dc9610ee1e38" + }, + "marketing-psychology": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/marketing-psychology/SKILL.md", + "computedHash": "50d735eb0f5c36f379ed2200f21aeacc9b9ad85667b5d33ec6111857352a1340" + }, + "onboarding": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/onboarding/SKILL.md", + "computedHash": "634b9ba0a4446d9358d3cbc316feefc2e9d618cd88d5a77f0bc6ec895ed5eb5f" + }, + "paywalls": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/paywalls/SKILL.md", + "computedHash": "1f0b8d15770d2e7737b818408dc5877b2eadc4cab072cdfb3529349ab059815f" + }, + "popups": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/popups/SKILL.md", + "computedHash": "95c5cb45dcbb05362168255d97ee68d53a5916d8de779bd08f5f70c9b0cabd36" + }, + "pricing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/pricing/SKILL.md", + "computedHash": "8a58f814ed556274658a39c57cfafe51c7a53736fdd9400bd91faa41abe84ca5" + }, + "product-marketing": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/product-marketing/SKILL.md", + "computedHash": "2fe9e9093e385345d17e0e77d032d68015417c37ceffeb5fb34fc761c46c94a0" + }, + "programmatic-seo": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/programmatic-seo/SKILL.md", + "computedHash": "0bbdcbacfbe46fd78d774041ee49afd313505bb435b775dc246f4b8e6981d797" + }, + "prospecting": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/prospecting/SKILL.md", + "computedHash": "63049127e427e260538e236301fcb7cfa87b2fa2938fe80d372f904f9d306223" + }, + "public-relations": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/public-relations/SKILL.md", + "computedHash": "1b13653b478b49049e4df4981ea032de63833387c4f914eadaeb070f6ecbdc5c" + }, + "referrals": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/referrals/SKILL.md", + "computedHash": "1b9228f8c74abd2ad43f72baf34e7de1727dc56fba8ed8a54588bec181c39c97" + }, + "revops": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/revops/SKILL.md", + "computedHash": "3ed71e1704d4c27f846ac7a207e66030c91fe6fa7f8ce83bcf7f30fda3badb6d" + }, + "sales-enablement": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/sales-enablement/SKILL.md", + "computedHash": "bb9db77dcdc6536194d5942bf42dcfb0e04853512a7c2dd70e0ead2709c53028" + }, + "schema": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/schema/SKILL.md", + "computedHash": "2f4807070c77b5c52af3a7e56d2ca5ee12ae2fd8b749d76ab68d3de40f323e78" + }, + "seo-audit": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/seo-audit/SKILL.md", + "computedHash": "f2dc52130189068981f21e1c0bdc4471ac7cd9b08d8a1320604411aa0f950385" + }, + "signup": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/signup/SKILL.md", + "computedHash": "9efbe9bc357581a924baa33a696b6d58a7126b7a4e4c472cebf18ed3d52cd2fd" + }, + "site-architecture": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/site-architecture/SKILL.md", + "computedHash": "48a38c5e2b000d134c3db282364b1014bad362fa91d4618ad8c2eb7cee991208" + }, + "sms": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/sms/SKILL.md", + "computedHash": "289111572055b6dd00a34e2111b6d895081b13cb3bf8c8fa3b05717d0e2e2b52" + }, + "social": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/social/SKILL.md", + "computedHash": "8c8e87a941d4cb53827e929ee457d9710bb81c5fb1ac80f19cd2aa034aeaa40d" + }, + "video": { + "source": "coreyhaines31/marketingskills", + "sourceType": "github", + "skillPath": "skills/video/SKILL.md", + "computedHash": "8286d39be8d86f9fc1c39b3c340cdebcf9a490f7606ffde566572baa163b1fe7" } } } diff --git a/src/components/FilePathToken.tsx b/src/components/FilePathToken.tsx new file mode 100644 index 0000000..f0dea19 --- /dev/null +++ b/src/components/FilePathToken.tsx @@ -0,0 +1,94 @@ +import { ExternalLink } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { getShellKind } from "@/runtime/shell-policy"; +import { cleanFilePathToken } from "@/lib/file-paths"; +import { cn, copyTextToClipboard, getErrorMessage } from "@/lib/utils"; + +export function FilePathToken({ + path, + baseDirectory, + className, +}: { + path: string; + baseDirectory?: string | null; + className?: string; +}) { + const { t } = useTranslation(); + const filePath = cleanFilePathToken(path); + const shellKind = getShellKind(); + const canUseShell = !!window.electronAPI?.openFile; + const [exists, setExists] = useState(false); + + useEffect(() => { + let cancelled = false; + setExists(false); + + if (!baseDirectory || shellKind === "mobile" || !window.electronAPI?.fileExists) return; + + window.electronAPI + .fileExists(filePath, baseDirectory) + .then((result) => { + if (!cancelled) setExists(result); + }) + .catch(() => { + if (!cancelled) setExists(false); + }); + + return () => { + cancelled = true; + }; + }, [baseDirectory, filePath, shellKind]); + + const copyPathFallback = async (messageKey: string) => { + await copyTextToClipboard(filePath); + toast.info(t(messageKey)); + }; + + const handleOpen = async () => { + if (shellKind === "mobile") { + await copyPathFallback("fileActions.mobileUnavailable"); + return; + } + + if (!canUseShell) { + await copyPathFallback("fileActions.openUnavailable"); + return; + } + + try { + const opened = await window.electronAPI?.openFile?.(filePath, baseDirectory ?? undefined); + if (!opened) { + await copyPathFallback("fileActions.openFailedCopied"); + } + } catch (error) { + await copyTextToClipboard(filePath); + toast.error(getErrorMessage(error, t("fileActions.openFailedCopied"))); + } + }; + + if (!exists) { + return ( + + {path} + + ); + } + + return ( + + ); +} diff --git a/src/components/LinkToken.tsx b/src/components/LinkToken.tsx new file mode 100644 index 0000000..6154be1 --- /dev/null +++ b/src/components/LinkToken.tsx @@ -0,0 +1,31 @@ +import { ExternalLink } from "lucide-react"; +import type { MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { cn, openExternalLink } from "@/lib/utils"; + +export function LinkToken({ url, className }: { url: string; className?: string }) { + const { t } = useTranslation(); + + const handleClick = (event: MouseEvent) => { + event.preventDefault(); + openExternalLink(url); + }; + + return ( + + {url} + + + ); +} diff --git a/src/components/MarkdownRenderer.tsx b/src/components/MarkdownRenderer.tsx index d07a284..14fbd41 100644 --- a/src/components/MarkdownRenderer.tsx +++ b/src/components/MarkdownRenderer.tsx @@ -14,7 +14,11 @@ import { import ReactMarkdown from "react-markdown"; import { Fragment, jsx, jsxs } from "react/jsx-runtime"; import remarkGfm from "remark-gfm"; +import { FilePathToken } from "@/components/FilePathToken"; +import { LinkToken } from "@/components/LinkToken"; import { Button } from "@/components/ui/button"; +import { isFilePathLike } from "@/lib/file-paths"; +import { isWebUrl } from "@/lib/urls"; import { cn, copyTextToClipboard, openExternalLink } from "@/lib/utils"; type StarryNight = Awaited>; @@ -141,62 +145,89 @@ function StarryCodeBlock({ children, className }: ComponentProps<"code">) { ); } -const markdownComponents = { - table({ children, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) { - return ( -
- {children}
-
- ); - }, - pre({ children }: ComponentProps<"pre">) { - // react-markdown applies component mappings before passing children to `pre`, - // so the child type is our mapped `code` function, not the literal "code" tag. - // Treat any single React element child with code-like props as a fenced block. - if (isValidElement>(children)) { +function createMarkdownComponents(baseDirectory?: string | null) { + return { + table({ children, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) { return ( - - {children.props.children} - +
+ {children}
+
); - } + }, + pre({ children }: ComponentProps<"pre">) { + // react-markdown applies component mappings before passing children to `pre`, + // so the child type is our mapped `code` function, not the literal "code" tag. + // Treat any single React element child with code-like props as a fenced block. + if (isValidElement>(children)) { + return ( + + {children.props.children} + + ); + } + + return
{children}
; + }, + a({ children, href, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) { + if (href && isFilePathLike(href)) { + return ; + } + if (isWebUrl(href) && nodeToString(children) === href) { + return ; + } + + const handleClick = (event: MouseEvent) => { + if (!href) return; + event.preventDefault(); + openExternalLink(href); + }; - return
{children}
; - }, - a({ children, href, node: _node, ...props }: ComponentProps<"a"> & { node?: unknown }) { - const handleClick = (event: MouseEvent) => { - if (!href) return; - event.preventDefault(); - openExternalLink(href); - }; + return ( + + {children} + + ); + }, + code({ children, node: _node, ...props }: ComponentProps<"code"> & { node?: unknown }) { + const value = nodeToString(children); + if (!props.className && isWebUrl(value)) { + return ; + } + if (!props.className && isFilePathLike(value)) { + return ; + } - return ( - - {children} - - ); - }, - code({ children, node: _node, ...props }: ComponentProps<"code"> & { node?: unknown }) { - return ( - - {children} - - ); - }, -}; + return ( + + {children} + + ); + }, + }; +} -export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) { +export const MarkdownRenderer = memo(function MarkdownRenderer({ + content, + baseDirectory, +}: { + content: string; + baseDirectory?: string | null; +}) { const remarkPlugins = useMemo(() => [remarkGfm], []); + const markdownComponents = useMemo( + () => createMarkdownComponents(baseDirectory), + [baseDirectory], + ); return (
diff --git a/src/components/PromptAddMenu.tsx b/src/components/PromptAddMenu.tsx index 8d11e2d..93df7ab 100644 --- a/src/components/PromptAddMenu.tsx +++ b/src/components/PromptAddMenu.tsx @@ -36,7 +36,7 @@ export function PromptAddMenu({ {t("prompt.add")} - + { e.stopPropagation(); diff --git a/src/components/message-list/TextPartView.tsx b/src/components/message-list/TextPartView.tsx index 6de65e5..ddbc525 100644 --- a/src/components/message-list/TextPartView.tsx +++ b/src/components/message-list/TextPartView.tsx @@ -48,7 +48,7 @@ export function TextPartView({ return (
- +
); } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d768e5d..5449947 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -168,6 +168,13 @@ "closeOtherProjects": "Andere Projekte schließen", "createPullRequest": "PR erstellen" }, + "fileActions": { + "openFile": "Datei öffnen", + "openLink": "Link öffnen", + "mobileUnavailable": "Lokale Workspace-Dateien können auf Mobilgeräten nicht geöffnet werden. Pfad kopiert.", + "openUnavailable": "Dateien können hier nicht geöffnet werden. Pfad kopiert.", + "openFailedCopied": "Datei konnte nicht geöffnet werden. Pfad kopiert." + }, "prompt": { "inputLabel": "Nachrichteneingabe", "selectOrCreateSession": "Sitzung auswählen oder erstellen...", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bfbee0d..8c87b89 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -165,6 +165,13 @@ "closeOtherProjects": "Close other projects", "createPullRequest": "Create PR" }, + "fileActions": { + "openFile": "Open file", + "openLink": "Open link", + "mobileUnavailable": "Opening local workspace files is not available on mobile. Path copied.", + "openUnavailable": "Opening files is not available here. Path copied.", + "openFailedCopied": "Could not open file. Path copied." + }, "prompt": { "inputLabel": "Message input", "selectOrCreateSession": "Select or create a session...", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2f6f3ba..3961856 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -168,6 +168,13 @@ "closeOtherProjects": "Cerrar otros proyectos", "createPullRequest": "Crear PR" }, + "fileActions": { + "openFile": "Abrir archivo", + "openLink": "Abrir enlace", + "mobileUnavailable": "Abrir archivos locales del workspace no está disponible en móvil. Ruta copiada.", + "openUnavailable": "Abrir archivos no está disponible aquí. Ruta copiada.", + "openFailedCopied": "No se pudo abrir el archivo. Ruta copiada." + }, "prompt": { "inputLabel": "Entrada de mensaje", "selectOrCreateSession": "Selecciona o crea una sesión...", diff --git a/src/lib/file-paths.ts b/src/lib/file-paths.ts new file mode 100644 index 0000000..8a30e7d --- /dev/null +++ b/src/lib/file-paths.ts @@ -0,0 +1,22 @@ +const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/; +const UNC_PATH_RE = /^\\\\[^\\/]+[\\/][^\\/]+/; +const FILE_EXTENSION_RE = /(^|[\\/])[^\\/<>:"|?*\n\r]+\.[A-Za-z0-9]{1,12}$/; + +export function cleanFilePathToken(value: string) { + return value.trim().replace(/^["']|["']$/g, ""); +} + +export function isAbsolutePathLike(value: string) { + return value.startsWith("/") || WINDOWS_ABSOLUTE_PATH_RE.test(value) || UNC_PATH_RE.test(value); +} + +export function isFilePathLike(value: string) { + const cleaned = cleanFilePathToken(value); + if (!cleaned || cleaned.length > 1024) return false; + if (cleaned.includes("\n") || cleaned.includes("\r") || cleaned.includes("\u0000")) return false; + if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned) && !WINDOWS_ABSOLUTE_PATH_RE.test(cleaned)) return false; + if (!cleaned.includes("/") && !cleaned.includes("\\")) return false; + if (!FILE_EXTENSION_RE.test(cleaned)) return false; + if (/^[-–—]/.test(cleaned)) return false; + return true; +} diff --git a/src/lib/urls.ts b/src/lib/urls.ts new file mode 100644 index 0000000..e6bcbf9 --- /dev/null +++ b/src/lib/urls.ts @@ -0,0 +1,9 @@ +export function isWebUrl(value: unknown): value is string { + if (typeof value !== "string" || !value.trim()) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/lib/web-electron-api.ts b/src/lib/web-electron-api.ts index fbfdfcf..1dbec5d 100644 --- a/src/lib/web-electron-api.ts +++ b/src/lib/web-electron-api.ts @@ -159,6 +159,12 @@ export function installWebElectronAPI() { getDetachedProjects: () => invoke("window:getDetachedProjects"), onDetachedProjectsChange: () => () => {}, openExternal: (url: string) => invoke("shell:openExternal", url), + fileExists: (filePath: string, baseDirectory?: string) => + invoke("shell:fileExists", filePath, baseDirectory), + openFile: (filePath: string, baseDirectory?: string) => + invoke("shell:openFile", filePath, baseDirectory), + showFileInFolder: (filePath: string, baseDirectory?: string) => + invoke("shell:showFileInFolder", filePath, baseDirectory), updates: { getState: async () => ({ status: "idle" }), check: async () => undefined, diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 1be8cef..caedc55 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -422,6 +422,15 @@ export interface ElectronAPI { openExternal: (url: string) => Promise; updates: UpdatesBridge; + /** Check whether a file exists. Relative paths are resolved against baseDirectory. */ + fileExists?: (filePath: string, baseDirectory?: string) => Promise; + + /** Open a file with the system default application. Relative paths are resolved against baseDirectory. */ + openFile?: (filePath: string, baseDirectory?: string) => Promise; + + /** Reveal a file in the system file browser. Relative paths are resolved against baseDirectory. */ + showFileInFolder?: (filePath: string, baseDirectory?: string) => Promise; + /** Open a directory in the system file browser (Finder / Explorer / Nautilus). */ openInFileBrowser: (dirPath: string, command?: string) => Promise; From f1a136a232a4af99a9ce9dbda28b528124a94e4b Mon Sep 17 00:00:00 2001 From: akemmanuel Date: Mon, 15 Jun 2026 15:33:52 -0300 Subject: [PATCH 5/5] fix: address review feedback --- lib/open-file-target.ts | 48 ++++++++ main.ts | 46 ++------ opencode-bridge.ts | 8 +- server/services/prompt-queue-service.ts | 15 ++- server/shell-ipc-handlers.ts | 52 ++------- skills-lock.json | 6 - .../VirtualMessageScroller.test.ts | 8 +- .../message-list/VirtualMessageScroller.tsx | 16 ++- src/components/ui/toggle-group.tsx | 6 +- src/hooks/agent-reducer.ts | 66 +++++++++-- src/hooks/use-agent-impl-core.tsx | 108 ------------------ src/hooks/use-prompt-submit.ts | 53 +++++---- src/lib/file-paths.test.ts | 21 ++++ src/lib/file-paths.ts | 29 ++++- 14 files changed, 237 insertions(+), 245 deletions(-) create mode 100644 lib/open-file-target.ts create mode 100644 src/lib/file-paths.test.ts diff --git a/lib/open-file-target.ts b/lib/open-file-target.ts new file mode 100644 index 0000000..ae73058 --- /dev/null +++ b/lib/open-file-target.ts @@ -0,0 +1,48 @@ +import { access, realpath, stat } from "node:fs/promises"; +import { isAbsolute, relative, resolve, win32 } from "node:path"; + +export function cleanOpenPath(value: unknown) { + if (typeof value !== "string") return ""; + return value.trim().replace(/^["']|["']$/g, ""); +} + +export function isPathInside(candidate: string, parent: string) { + const childRelative = relative(parent, candidate); + return ( + childRelative === "" || + (!!childRelative && !childRelative.startsWith("..") && !isAbsolute(childRelative)) + ); +} + +function isExpectedFileSystemError(error: unknown) { + if (!error || typeof error !== "object" || !("code" in error)) return false; + return ["EACCES", "EBUSY", "ELOOP", "EMFILE", "ENOENT", "ENOTDIR", "EPERM"].includes( + String(error.code), + ); +} + +export async function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) { + const target = cleanOpenPath(filePath); + if (!target || target.includes("\0")) return null; + + const base = cleanOpenPath(baseDirectory); + const normalizedBase = base ? resolve(base) : null; + if (!normalizedBase) return null; + + const targetAbsolute = + isAbsolute(target) || (process.platform === "win32" && win32.isAbsolute(target)); + const resolved = targetAbsolute ? resolve(target) : resolve(normalizedBase, target); + if (!isPathInside(resolved, normalizedBase)) return null; + + try { + const realBase = await realpath(normalizedBase); + const realResolved = await realpath(resolved); + if (!isPathInside(realResolved, realBase)) return null; + await access(realResolved); + if (!(await stat(realResolved)).isFile()) return null; + return realResolved; + } catch (error) { + if (!isExpectedFileSystemError(error)) throw error; + return null; + } +} diff --git a/main.ts b/main.ts index bace3f6..cb8898d 100644 --- a/main.ts +++ b/main.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); const { app, BrowserWindow, dialog, ipcMain, shell, session } = require("electron") as typeof import("electron"); -import { existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { execSync, spawn } from "node:child_process"; import type { SpawnOptions } from "node:child_process"; @@ -15,6 +14,7 @@ import { createBackendSidecarController } from "./main/backend-sidecar.js"; import { broadcastToAllWindows } from "./lib/window-broadcast.js"; import { findFilesInDirectory } from "./server/services/file-search.js"; import { getHarnessInventories } from "./server/harness-inventory.js"; +import { resolveFileTarget } from "./lib/open-file-target.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -63,38 +63,6 @@ function spawnCustomCommand(command: unknown, options: SpawnOptions = {}) { return true; } -function cleanOpenPath(value: unknown) { - if (typeof value !== "string") return ""; - return value.trim().replace(/^["']|["']$/g, ""); -} - -function isPathInside(candidate: string, parent: string) { - const relative = path.relative(parent, candidate); - return ( - relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) - ); -} - -function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) { - const target = cleanOpenPath(filePath); - if (!target || target.includes("\0")) return null; - - const base = cleanOpenPath(baseDirectory); - const normalizedBase = base ? path.resolve(base) : null; - if (!normalizedBase) return null; - - const isAbsolute = path.isAbsolute(target) || path.win32.isAbsolute(target); - const resolved = isAbsolute ? path.resolve(target) : path.resolve(normalizedBase, target); - if (normalizedBase && !isPathInside(resolved, normalizedBase)) return null; - - try { - if (!existsSync(resolved) || !statSync(resolved).isFile()) return null; - } catch { - return null; - } - return resolved; -} - function getDesktopTerminalCommand() { const gsettingsKeys = [ "org.cinnamon.desktop.default-applications.terminal exec", @@ -516,19 +484,21 @@ ipcMain.handle("shell:openExternal", (_event, url) => { if (isWebUrl(url)) void shell.openExternal(url); }); -ipcMain.handle("shell:fileExists", (_event, filePath, baseDirectory) => { - return !!resolveFileTarget(filePath, baseDirectory); +// Electron main-process handlers. The browser-only web server registers the +// same channel names against its FakeIpcMain in server/shell-ipc-handlers.ts. +ipcMain.handle("shell:fileExists", async (_event, filePath, baseDirectory) => { + return !!(await resolveFileTarget(filePath, baseDirectory)); }); ipcMain.handle("shell:openFile", async (_event, filePath, baseDirectory) => { - const target = resolveFileTarget(filePath, baseDirectory); + const target = await resolveFileTarget(filePath, baseDirectory); if (!target) return false; const error = await shell.openPath(target); return !error; }); -ipcMain.handle("shell:showFileInFolder", (_event, filePath, baseDirectory) => { - const target = resolveFileTarget(filePath, baseDirectory); +ipcMain.handle("shell:showFileInFolder", async (_event, filePath, baseDirectory) => { + const target = await resolveFileTarget(filePath, baseDirectory); if (!target) return false; shell.showItemInFolder(target); return true; diff --git a/opencode-bridge.ts b/opencode-bridge.ts index 017cdaa..62291a4 100644 --- a/opencode-bridge.ts +++ b/opencode-bridge.ts @@ -1889,10 +1889,12 @@ export function setupOpenCodeBridge(ipcMain, _getWindows) { } }); - handleSessionOp("opencode:session:revert", async (conn, id, messageID, partID) => - tagOpenCodeSession(await conn.revertSession(id, messageID, partID), conn.getDirectory()), + handleSessionOp( + "opencode:session:revert", + async (conn, id, messageID, partID, _directory, _workspaceId) => + tagOpenCodeSession(await conn.revertSession(id, messageID, partID), conn.getDirectory()), ); - handleSessionOp("opencode:session:unrevert", async (conn, id) => + handleSessionOp("opencode:session:unrevert", async (conn, id, _directory, _workspaceId) => tagOpenCodeSession(await conn.unrevertSession(id), conn.getDirectory()), ); diff --git a/server/services/prompt-queue-service.ts b/server/services/prompt-queue-service.ts index 73e5544..14f1486 100644 --- a/server/services/prompt-queue-service.ts +++ b/server/services/prompt-queue-service.ts @@ -1,4 +1,7 @@ import type { QueueMode } from "../../src/lib/session-drafts.ts"; +import { access, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { SelectedModel } from "../../src/types/electron.d.ts"; import type { BackendEventBus } from "./event-bus.ts"; import type { ProjectService } from "./project-service.ts"; @@ -284,7 +287,17 @@ export class PromptQueueService { // Older Session records sometimes used the directory itself as projectId. // Keep queue usable for those records instead of failing a running Session. if (session.projectId.startsWith("/") || session.projectId.startsWith("~")) { - return session.projectId; + const legacyPath = session.projectId.startsWith("~") + ? join(homedir(), session.projectId.slice(1)) + : session.projectId; + try { + await access(legacyPath); + if ((await stat(legacyPath)).isDirectory()) { + return legacyPath; + } + } catch { + // Fall through to the normal missing-project error. + } } throw new Error("Project not found"); diff --git a/server/shell-ipc-handlers.ts b/server/shell-ipc-handlers.ts index 5240d37..eacf878 100644 --- a/server/shell-ipc-handlers.ts +++ b/server/shell-ipc-handlers.ts @@ -1,9 +1,10 @@ import { spawn } from "node:child_process"; -import { existsSync, statSync } from "node:fs"; -import { dirname, isAbsolute, relative, resolve, win32 } from "node:path"; +import { existsSync } from "node:fs"; +import { dirname } from "node:path"; import { homedir } from "node:os"; import type { BackendServiceContext } from "./services/index.ts"; import { getHarnessInventories } from "./harness-inventory.ts"; +import { resolveFileTarget } from "../lib/open-file-target.ts"; interface IpcSender { send(channel: string, data: unknown): void; @@ -61,39 +62,6 @@ function showFileInFolder(path: string) { else openPath(dirname(path)); } -function cleanOpenPath(value: unknown) { - if (typeof value !== "string") return ""; - return value.trim().replace(/^["']|["']$/g, ""); -} - -function isPathInside(candidate: string, parent: string) { - const childRelative = relative(parent, candidate); - return ( - childRelative === "" || - (!!childRelative && !childRelative.startsWith("..") && !isAbsolute(childRelative)) - ); -} - -function resolveFileTarget(filePath: unknown, baseDirectory?: unknown) { - const target = cleanOpenPath(filePath); - if (!target || target.includes("\0")) return null; - - const base = cleanOpenPath(baseDirectory); - const normalizedBase = base ? resolve(base) : null; - if (!normalizedBase) return null; - - const targetAbsolute = isAbsolute(target) || win32.isAbsolute(target); - const resolved = targetAbsolute ? resolve(target) : resolve(normalizedBase, target); - if (normalizedBase && !isPathInside(resolved, normalizedBase)) return null; - - try { - if (!existsSync(resolved) || !statSync(resolved).isFile()) return null; - } catch { - return null; - } - return resolved; -} - async function runPicker(command: string[]) { const [file, ...args] = command; if (!file) return null; @@ -229,17 +197,19 @@ export function registerShellIpcHandlers(input: { ipcMain.handle("shell:openExternal", (_event, url) => openExternal(typeof url === "string" ? url : ""), ); - ipcMain.handle("shell:fileExists", (_event, filePath, baseDirectory) => { - return !!resolveFileTarget(filePath, baseDirectory); + // Browser-only web-server handlers registered on FakeIpcMain. Electron's + // real main process registers equivalent channels in main.ts. + ipcMain.handle("shell:fileExists", async (_event, filePath, baseDirectory) => { + return !!(await resolveFileTarget(filePath, baseDirectory)); }); - ipcMain.handle("shell:openFile", (_event, filePath, baseDirectory) => { - const target = resolveFileTarget(filePath, baseDirectory); + ipcMain.handle("shell:openFile", async (_event, filePath, baseDirectory) => { + const target = await resolveFileTarget(filePath, baseDirectory); if (!target) return false; openPath(target); return true; }); - ipcMain.handle("shell:showFileInFolder", (_event, filePath, baseDirectory) => { - const target = resolveFileTarget(filePath, baseDirectory); + ipcMain.handle("shell:showFileInFolder", async (_event, filePath, baseDirectory) => { + const target = await resolveFileTarget(filePath, baseDirectory); if (!target) return false; showFileInFolder(target); return true; diff --git a/skills-lock.json b/skills-lock.json index d7ebe7d..30a293d 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -19,12 +19,6 @@ "skillPath": "skills/ads/SKILL.md", "computedHash": "3f623f4dd628a2989bf9e181bb8100ddf8998cbacbbfa1dbcff69642fedd9da0" }, - "agent-browser": { - "source": "vercel-labs/agent-browser", - "sourceType": "github", - "skillPath": "skills/agent-browser/SKILL.md", - "computedHash": "228f87d57035100d9dc6efcfc05aafd4b6e3962adacaa04b8217ab2fadb15dc8" - }, "ai-seo": { "source": "coreyhaines31/marketingskills", "sourceType": "github", diff --git a/src/components/message-list/VirtualMessageScroller.test.ts b/src/components/message-list/VirtualMessageScroller.test.ts index b585b3f..38cbfe6 100644 --- a/src/components/message-list/VirtualMessageScroller.test.ts +++ b/src/components/message-list/VirtualMessageScroller.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; import { distanceFromBottom, getScrollSnapshotFlags, - isAtTop, + isNearTop, isNearBottom, shouldLoadOlderMessages, } from "./VirtualMessageScroller"; @@ -15,7 +15,7 @@ describe("VirtualMessageScroller scroll position helpers", () => { test("does not treat top of an overflowing chat as pinned to bottom", () => { const element = scrollElement({ scrollHeight: 5000, scrollTop: 0, clientHeight: 800 }); - expect(isAtTop(element)).toBe(true); + expect(isNearTop(element)).toBe(true); expect(distanceFromBottom(element)).toBe(4200); expect(isNearBottom(element)).toBe(false); }); @@ -23,7 +23,7 @@ describe("VirtualMessageScroller scroll position helpers", () => { test("treats real bottom as pinned to bottom", () => { const element = scrollElement({ scrollHeight: 5000, scrollTop: 4200, clientHeight: 800 }); - expect(isAtTop(element)).toBe(false); + expect(isNearTop(element)).toBe(false); expect(distanceFromBottom(element)).toBe(0); expect(isNearBottom(element)).toBe(true); }); @@ -31,7 +31,7 @@ describe("VirtualMessageScroller scroll position helpers", () => { test("keeps short non-scrollable chats pinned to bottom", () => { const element = scrollElement({ scrollHeight: 700, scrollTop: 0, clientHeight: 800 }); - expect(isAtTop(element)).toBe(true); + expect(isNearTop(element)).toBe(true); expect(distanceFromBottom(element)).toBe(0); expect(isNearBottom(element)).toBe(true); }); diff --git a/src/components/message-list/VirtualMessageScroller.tsx b/src/components/message-list/VirtualMessageScroller.tsx index b38a480..4c693d8 100644 --- a/src/components/message-list/VirtualMessageScroller.tsx +++ b/src/components/message-list/VirtualMessageScroller.tsx @@ -48,7 +48,7 @@ export function distanceFromBottom( return Math.max(0, element.scrollHeight - element.scrollTop - element.clientHeight); } -export function isAtTop(element: Pick) { +export function isNearTop(element: Pick) { return element.scrollTop <= NEAR_BOTTOM_PX; } @@ -57,7 +57,7 @@ export function isNearBottom( ) { const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); if (maxScrollTop <= NEAR_BOTTOM_PX) return true; - if (isAtTop(element)) return false; + if (isNearTop(element)) return false; return distanceFromBottom(element) <= NEAR_BOTTOM_PX; } @@ -87,7 +87,7 @@ export function getScrollSnapshotFlags( ) { const pinnedToBottom = wasPinnedToBottom || isNearBottom(element); return { - atTop: !pinnedToBottom && isAtTop(element), + atTop: !pinnedToBottom && isNearTop(element), pinnedToBottom, }; } @@ -197,7 +197,7 @@ export function VirtualMessageScroller({ const maybeLoadOlder = useCallback(() => { const scrollEl = scrollRef.current; - if (!scrollEl || pinnedToBottomRef.current || !isAtTop(scrollEl)) return; + if (!scrollEl || pinnedToBottomRef.current) return; const firstIndex = virtualizer.getVirtualItems()[0]?.index; if ( @@ -355,7 +355,13 @@ export function VirtualMessageScroller({ const scrollbarWidth = el.offsetWidth - el.clientWidth; if (scrollbarWidth <= 0) return; const rect = el.getBoundingClientRect(); - if (event.clientX >= rect.right - scrollbarWidth - 2) markUserScrollIntent(); + const isRtl = getComputedStyle(el).direction === "rtl"; + const isScrollbarClick = isRtl + ? event.clientX <= rect.left + scrollbarWidth + 2 + : event.clientX >= rect.right - scrollbarWidth - 2; + if (isScrollbarClick) { + markUserScrollIntent(); + } }, [markUserScrollIntent], ); diff --git a/src/components/ui/toggle-group.tsx b/src/components/ui/toggle-group.tsx index 8e5d941..d697cf8 100644 --- a/src/components/ui/toggle-group.tsx +++ b/src/components/ui/toggle-group.tsx @@ -60,7 +60,7 @@ function ToggleGroup({ ? undefined : isMultiple ? (value as readonly string[]) - : value + : value != null ? [value as string] : []; const primitiveDefaultValue = @@ -68,7 +68,7 @@ function ToggleGroup({ ? undefined : isMultiple ? (defaultValue as readonly string[]) - : defaultValue + : defaultValue != null ? [defaultValue as string] : []; @@ -78,6 +78,8 @@ function ToggleGroup({ return; } + // Radix represents a cleared single-selection as [], while this wrapper's + // public single-mode API uses an empty string for "no selection". (onValueChange as ((value: string) => void) | undefined)?.(nextValue[0] ?? ""); }; diff --git a/src/hooks/agent-reducer.ts b/src/hooks/agent-reducer.ts index 75a5157..5b34b22 100644 --- a/src/hooks/agent-reducer.ts +++ b/src/hooks/agent-reducer.ts @@ -104,6 +104,43 @@ function getTurnRunIdForSession(state: InternalAgentState, sessionID: string) { return undefined; } +function getSessionIdentityKeys(state: InternalAgentState, sessionID: string) { + const keys = new Set([sessionID]); + const matchingSessions = state.sessions.filter( + (session) => session.id === sessionID || getBackendSessionIdentity(session) === sessionID, + ); + for (const matchingSession of matchingSessions) { + keys.add(matchingSession.id); + keys.add(getBackendSessionIdentity(matchingSession)); + } + return keys; +} + +function removeSessionError(errors: Record, sessionID: string) { + if (!errors[sessionID]) return errors; + const next = { ...errors }; + delete next[sessionID]; + return next; +} + +function getNextSessionStatusErrors({ + current, + sessionID, + statusType, + retryMessage, +}: { + current: Record; + sessionID: string; + statusType: string; + retryMessage: string | null; +}) { + if (retryMessage) return { ...current, [sessionID]: retryMessage }; + if (statusType === "busy" || statusType === "idle" || statusType === "retry") { + return removeSessionError(current, sessionID); + } + return current; +} + function bindAssistantMessageToActiveTurn(state: InternalAgentState, msg: Message) { if (msg.role !== "assistant") return null; const activeTurnId = getTurnRunIdForSession(state, msg.sessionID); @@ -923,7 +960,11 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen const run = action.payload; const busySessionIds = new Set(state.busySessionIds); busySessionIds.add(run.sessionID); - const { [run.sessionID]: _clearedSessionError, ...sessionErrors } = state.sessionErrors; + const sessionErrors = state.sessionErrors[run.sessionID] + ? Object.fromEntries( + Object.entries(state.sessionErrors).filter(([id]) => id !== run.sessionID), + ) + : state.sessionErrors; return { ...state, sessionErrors, @@ -944,15 +985,15 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen return { ...state, lastError: action.payload, - ...(action.payload === null ? { sessionErrors: {} } : {}), }; case "SESSION_ERROR": { const { sessionID, error } = action.payload; if (!sessionID) return { ...state, lastError: error }; + const sessionIdentityKeys = getSessionIdentityKeys(state, sessionID); const newBusy = new Set(state.busySessionIds); - newBusy.delete(sessionID); + sessionIdentityKeys.forEach((key) => newBusy.delete(key)); const activeTurnId = getTurnRunIdForSession(state, sessionID); const activeTurn = activeTurnId ? state.turnRuns[activeTurnId] : undefined; const nextTurnRuns = @@ -968,7 +1009,7 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen : state.turnRuns; const nextActiveTurnRunBySession = Object.fromEntries( Object.entries(state.activeTurnRunBySession).filter(([sid, turnId]) => { - if (sid === sessionID) return false; + if (sessionIdentityKeys.has(sid)) return false; return turnId !== activeTurnId; }), ); @@ -980,7 +1021,9 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen busySessionIds: newBusy, turnRuns: nextTurnRuns, activeTurnRunBySession: nextActiveTurnRunBySession, - ...(sessionID === state.activeSessionId ? { isBusy: false } : {}), + ...(state.activeSessionId && sessionIdentityKeys.has(state.activeSessionId) + ? { isBusy: false } + : {}), }; } @@ -1724,13 +1767,12 @@ export function reducer(state: InternalAgentState, action: Action): InternalAgen } else { newBusy.delete(sessionID); } - const nextSessionErrors = retryMessage - ? { ...state.sessionErrors, [sessionID]: retryMessage } - : status.type === "idle" && state.sessionErrors[sessionID] - ? Object.fromEntries( - Object.entries(state.sessionErrors).filter(([id]) => id !== sessionID), - ) - : state.sessionErrors; + const nextSessionErrors = getNextSessionStatusErrors({ + current: state.sessionErrors, + sessionID, + statusType: status.type, + retryMessage, + }); // Keep session buffer cached even when session goes idle so // switching back to it is instant (LRU eviction handles cleanup). const nextBuffers = state._sessionBuffers; diff --git a/src/hooks/use-agent-impl-core.tsx b/src/hooks/use-agent-impl-core.tsx index 3a26811..a2b9ceb 100644 --- a/src/hooks/use-agent-impl-core.tsx +++ b/src/hooks/use-agent-impl-core.tsx @@ -2003,114 +2003,6 @@ function InternalAgentProvider({ } }, [state.bootState]); - const getQueuedPrompts = useCallback( - (sessionId: string) => state.queuedPrompts[sessionId] ?? [], - [state.queuedPrompts], - ); - - const applyQueueSnapshot = useCallback( - (sessionId: string, prompts: (typeof state.queuedPrompts)[string]) => { - dispatch({ type: "SET_SESSION_QUEUE", payload: { sessionID: sessionId, prompts } }); - }, - [], - ); - - const getQueueTarget = useCallback((sessionId: string) => { - const session = stateRef.current.sessions.find((item) => item.id === sessionId); - const harnessId = resolveSessionHarnessRoute(session).harnessId ?? undefined; - const target = - getSessionProjectTarget( - session, - session ? stateRef.current.sessionMeta[session.id] : undefined, - ) ?? undefined; - const workspaceId = getSessionWorkspaceId(session) ?? undefined; - if (!harnessId) { - throw new Error("Queued prompt target requires Harness and Session ID"); - } - return { - harnessId, - target: target ?? (workspaceId ? { workspaceId } : undefined), - }; - }, []); - - const removeFromQueue = useCallback( - (sessionId: string, promptId: string) => { - let queueTarget: ReturnType; - try { - queueTarget = getQueueTarget(sessionId); - } catch (error) { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to resolve queued prompt target", - }); - return; - } - void openGuiClient.sessions.queue - .remove({ sessionId, entryId: promptId, ...queueTarget }) - .then((prompts) => applyQueueSnapshot(sessionId, prompts)) - .catch((error) => { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to remove queued prompt", - }); - }); - }, - [applyQueueSnapshot, getQueueTarget, openGuiClient], - ); - - const reorderQueue = useCallback( - (sessionId: string, fromIndex: number, toIndex: number) => { - const existing = stateRef.current.queuedPrompts[sessionId] ?? []; - const entryId = existing[fromIndex]?.id; - if (!entryId) return; - let queueTarget: ReturnType; - try { - queueTarget = getQueueTarget(sessionId); - } catch (error) { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to resolve queued prompt target", - }); - return; - } - void openGuiClient.sessions.queue - .reorder({ sessionId, entryId, index: toIndex, ...queueTarget }) - .then((prompts) => applyQueueSnapshot(sessionId, prompts)) - .catch((error) => { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to reorder queue", - }); - }); - }, - [applyQueueSnapshot, getQueueTarget, openGuiClient], - ); - - const updateQueuedPrompt = useCallback( - (sessionId: string, promptId: string, text: string) => { - let queueTarget: ReturnType; - try { - queueTarget = getQueueTarget(sessionId); - } catch (error) { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to resolve queued prompt target", - }); - return; - } - void openGuiClient.sessions.queue - .update({ sessionId, entryId: promptId, text, ...queueTarget }) - .then((prompts) => applyQueueSnapshot(sessionId, prompts)) - .catch((error) => { - dispatch({ - type: "SET_ERROR", - payload: getErrorMessage(error) || "Failed to update queued prompt", - }); - }); - }, - [applyQueueSnapshot, getQueueTarget, openGuiClient], - ); - const setSessionDraft = useCallback((key: string, text: string) => { dispatch({ type: "SET_SESSION_DRAFT", payload: { key, text } }); }, []); diff --git a/src/hooks/use-prompt-submit.ts b/src/hooks/use-prompt-submit.ts index ad9aaeb..75dc0e0 100644 --- a/src/hooks/use-prompt-submit.ts +++ b/src/hooks/use-prompt-submit.ts @@ -4,6 +4,10 @@ import type { parseSlashCommand } from "@/hooks/use-slash-command-input"; type SlashInvocation = ReturnType; +function hasPromptText(value: string) { + return value.trim().length > 0; +} + export type PromptSubmitDecision = | { type: "skip" } | { type: "command"; commandName: string; args: string } @@ -24,7 +28,7 @@ export function decidePromptSubmit({ queueMode: QueueMode; slashInvocation: SlashInvocation; }): PromptSubmitDecision { - const hasValue = value.trim().length > 0; + const hasValue = hasPromptText(value); if (disabled || isUploading || !hasValue) return { type: "skip" }; if (slashInvocation) { return { @@ -98,46 +102,51 @@ export function usePromptSubmit({ resetHistory, }; - const hasValue = value.trim().length > 0; + const hasValue = hasPromptText(value); const submit = React.useCallback(async () => { const latest = latestRef.current; const currentValue = latest.value; if (submittingRef.current) return; - if (latest.disabled || latest.isUploading) return; - if (currentValue.trim().length === 0) return; submittingRef.current = true; - const decision = decidePromptSubmit({ - value: currentValue, - disabled: latest.disabled, - isUploading: latest.isUploading, - isLoading: latest.isLoading, - queueMode: latest.queueMode, - slashInvocation: latest.parseSlashCommand(currentValue), - }); + try { + if (latest.disabled || latest.isUploading) return; + if (!hasPromptText(currentValue)) return; - if (decision.type === "skip") { - submittingRef.current = false; - return; - } + const decision = decidePromptSubmit({ + value: currentValue, + disabled: latest.disabled, + isUploading: latest.isUploading, + isLoading: latest.isLoading, + queueMode: latest.queueMode, + slashInvocation: latest.parseSlashCommand(currentValue), + }); + + if (decision.type === "skip") return; - try { if (decision.type === "command") { + try { + await latest.sendCommand(decision.commandName, decision.args); + } catch (error) { + console.error("Failed to send command", error); + return; + } latest.clearPromptDraft(); latest.resetSlashCommand(); latest.resetHistory(); - await latest.sendCommand(decision.commandName, decision.args); return; } - const submission = Promise.resolve(latest.onSubmit?.(decision.text, decision.mode)); + try { + await latest.onSubmit?.(decision.text, decision.mode); + } catch (error) { + console.error("Failed to submit prompt", error); + return; + } latest.clearPromptDraft(); latest.onAfterSubmit?.(); latest.resetHistory(); - submission.catch((error) => { - console.error("Failed to submit prompt", error); - }); } finally { submittingRef.current = false; } diff --git a/src/lib/file-paths.test.ts b/src/lib/file-paths.test.ts new file mode 100644 index 0000000..869fcd4 --- /dev/null +++ b/src/lib/file-paths.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "@voidzero-dev/vite-plus-test"; +import { isFilePathLike } from "./file-paths"; + +describe("isFilePathLike", () => { + test("detects hidden dotfiles as file paths", () => { + expect(isFilePathLike(".env")).toBe(true); + expect(isFilePathLike("config/.gitignore")).toBe(true); + }); + + test("detects common extensionless file paths", () => { + expect(isFilePathLike("Dockerfile")).toBe(true); + expect(isFilePathLike("Procfile")).toBe(true); + expect(isFilePathLike("README")).toBe(true); + expect(isFilePathLike("/etc/hosts")).toBe(true); + }); + + test("detects bare filenames with extensions", () => { + expect(isFilePathLike("README.md")).toBe(true); + expect(isFilePathLike("package.json")).toBe(true); + }); +}); diff --git a/src/lib/file-paths.ts b/src/lib/file-paths.ts index 8a30e7d..843a561 100644 --- a/src/lib/file-paths.ts +++ b/src/lib/file-paths.ts @@ -1,6 +1,17 @@ const WINDOWS_ABSOLUTE_PATH_RE = /^[A-Za-z]:[\\/]/; const UNC_PATH_RE = /^\\\\[^\\/]+[\\/][^\\/]+/; -const FILE_EXTENSION_RE = /(^|[\\/])[^\\/<>:"|?*\n\r]+\.[A-Za-z0-9]{1,12}$/; +const FILE_EXTENSION_RE = /(^|[\\/])[^\\/<>:"|?*\n\r]*\.[A-Za-z0-9]{1,12}$/; +const EXTENSIONLESS_FILE_NAMES = new Set([ + "Brewfile", + "Dockerfile", + "Gemfile", + "Justfile", + "Makefile", + "Procfile", + "Rakefile", + "README", + "Vagrantfile", +]); export function cleanFilePathToken(value: string) { return value.trim().replace(/^["']|["']$/g, ""); @@ -15,8 +26,20 @@ export function isFilePathLike(value: string) { if (!cleaned || cleaned.length > 1024) return false; if (cleaned.includes("\n") || cleaned.includes("\r") || cleaned.includes("\u0000")) return false; if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned) && !WINDOWS_ABSOLUTE_PATH_RE.test(cleaned)) return false; - if (!cleaned.includes("/") && !cleaned.includes("\\")) return false; - if (!FILE_EXTENSION_RE.test(cleaned)) return false; + + const segments = cleaned.split(/[\\/]+/); + const basename = segments.at(-1) ?? ""; + const hasSeparator = cleaned.includes("/") || cleaned.includes("\\"); + const isDotfile = basename.startsWith(".") && basename.length > 1; + const hasExtension = FILE_EXTENSION_RE.test(cleaned); + const isKnownExtensionlessFile = EXTENSIONLESS_FILE_NAMES.has(basename); + + if (!hasSeparator && !isDotfile && !isKnownExtensionlessFile && !hasExtension) return false; + if (!basename || basename === "." || basename === "..") return false; + if (!hasExtension && !isDotfile && !isKnownExtensionlessFile && !isAbsolutePathLike(cleaned)) { + return false; + } + // Avoid treating command-line flags in terminal output as relative file paths. if (/^[-–—]/.test(cleaned)) return false; return true; }