fix(renderer): preserve desktop error codes
This commit is contained in:
1 parent
eec26a09a3
commit
1453a8fa95
15 files changed
+326
-40
No files matched your search
+11
-4
@@ -15,7 +15,7 @@ import { useCompanyController } from "./hooks/useCompanyController";
|
||||
import { useCommandPaletteHotkeys } from "./hooks/useCommandPaletteHotkeys";
|
||||
import { AppActionsContext, type AppActions } from "./hooks/useAppActions";
|
||||
import { SectionContext } from "./hooks/useSectionContext";
|
||||
import { unwrap } from "./lib/desktop";
|
||||
import { createAppErrorNotice, describeDesktopError, formatDesktopResultError, unwrap } from "./lib/desktop";
|
||||
|
||||
function Shell({
|
||||
locale,
|
||||
@@ -55,9 +55,16 @@ export function App() {
|
||||
const isMac = !showCustomTitleBar;
|
||||
|
||||
const startRun = useCallback(async (taskId: string) => {
|
||||
if (!company.currentCompany) { setError("Select a company before starting a run."); return; }
|
||||
if (!company.currentCompany) {
|
||||
setError(createAppErrorNotice(
|
||||
"Select a company before starting a run.",
|
||||
"COMPANY_REQUIRED",
|
||||
"Select a company in the sidebar, then start the run.",
|
||||
));
|
||||
return;
|
||||
}
|
||||
const result = await window.agentCompany.startTaskRun({ companyId: company.currentCompany.id, taskId });
|
||||
if (!result.ok) { setError(result.error.message); return; }
|
||||
if (!result.ok) { setError(formatDesktopResultError(result.error)); return; }
|
||||
setSelectedRunId(result.data);
|
||||
await company.refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Zustand setters and company.refresh are stable
|
||||
@@ -70,7 +77,7 @@ export function App() {
|
||||
unwrap(await window.agentCompany.cancelRun({ companyId: run.companyId, runId }));
|
||||
await company.refresh();
|
||||
setError(null);
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
} catch (err) { setError(describeDesktopError(err)); }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- setError is a stable Zustand setter
|
||||
}, [company.refresh, company.runs]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "framer-motion"
|
||||
import { CheckCircle, Warning, X, XCircle } from "./ui/icons"
|
||||
import { useAppStore } from "../state/store"
|
||||
import { useReducedMotion } from "./ui"
|
||||
import type { Toast } from "../state/slices/ui"
|
||||
|
||||
const TONE_CONFIG = {
|
||||
success: { icon: CheckCircle, className: "bg-success-muted border-success text-success" },
|
||||
@@ -34,9 +35,7 @@ export default function ToastContainer() {
|
||||
className={`flex w-[320px] items-start gap-2.5 rounded-lg border px-4 py-3 shadow-lg ${cfg.className}`}
|
||||
>
|
||||
<Icon size={16} weight="fill" className="mt-px shrink-0" />
|
||||
<span className="max-h-[6.5rem] min-w-0 flex-1 overflow-y-auto break-words text-[13px] font-medium leading-relaxed">
|
||||
{toast.message}
|
||||
</span>
|
||||
<ToastMessage message={toast.message} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
@@ -52,3 +51,25 @@ export default function ToastContainer() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastMessage({ message }: { message: Toast["message"] }) {
|
||||
if (typeof message === "string") {
|
||||
return (
|
||||
<span className="max-h-[6.5rem] min-w-0 flex-1 overflow-y-auto break-words text-[13px] font-medium leading-relaxed">
|
||||
{message}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[7.5rem] min-w-0 flex-1 overflow-y-auto">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.08em]">
|
||||
{message.code}
|
||||
</span>
|
||||
<span className="min-w-0 break-words text-[12px] font-semibold leading-5">{message.actionHint}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-words text-[12px] leading-5 opacity-80">{message.message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AgentRecord, SkillRecord } from "@shared/types";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { formatDesktopResultError } from "../../lib/desktop";
|
||||
|
||||
type SkillAgentTogglesProps =
|
||||
| { mode: "skill"; companyId: string; skillId: string; agents: AgentRecord[] }
|
||||
@@ -48,7 +49,7 @@ export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
setTogglingId(null);
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ActionButton, StatusPill, Eyebrow } from "../ui";
|
||||
import { SkillAgentToggles } from "./SkillAgentToggles";
|
||||
import { buildTree, SkillFileTree } from "./SkillFileTree";
|
||||
import { useT, type TranslationFn } from "../../i18n";
|
||||
import { formatDesktopResultError } from "../../lib/desktop";
|
||||
|
||||
const sourceIconMap: Record<string, typeof GithubLogo> = {
|
||||
github: GithubLogo,
|
||||
@@ -114,7 +115,7 @@ export function SkillDetail({
|
||||
if (result.ok) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
}
|
||||
void load();
|
||||
@@ -147,7 +148,7 @@ export function SkillDetail({
|
||||
if (result.ok) {
|
||||
setUpdateStatus(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
}, [companyId, skillId, detail, addToast]);
|
||||
|
||||
@@ -157,7 +158,7 @@ export function SkillDetail({
|
||||
addToast(t("skill.deleted"), "success");
|
||||
onDeleted();
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
}, [companyId, skillId, addToast, onDeleted, t]);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { Modal, ActionButton, Input } from "../ui";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { useT } from "../../i18n";
|
||||
import { describeDesktopError, formatDesktopResultError } from "../../lib/desktop";
|
||||
|
||||
type TabId = "github" | "local" | "url" | "skills_sh";
|
||||
|
||||
@@ -95,10 +96,10 @@ export function SkillImportModal({
|
||||
onImported();
|
||||
handleClose();
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : t("skillImport.failed"), "danger");
|
||||
addToast(describeDesktopError(error), "danger");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SkillList } from "./SkillList";
|
||||
import { SkillImportModal } from "./SkillImportModal";
|
||||
import { SkillDetail } from "./SkillDetail";
|
||||
import { useT } from "../../i18n";
|
||||
import { describeDesktopError, formatDesktopResultError } from "../../lib/desktop";
|
||||
|
||||
export function SkillsSection({
|
||||
companyId,
|
||||
@@ -37,10 +38,10 @@ export function SkillsSection({
|
||||
if (result.ok) {
|
||||
setSkills(result.data);
|
||||
} else {
|
||||
addToast(result.error.message, "danger");
|
||||
addToast(formatDesktopResultError(result.error), "danger");
|
||||
}
|
||||
} catch (error) {
|
||||
addToast(error instanceof Error ? error.message : "Failed to load skills", "danger");
|
||||
addToast(describeDesktopError(error), "danger");
|
||||
}
|
||||
}, [companyId, setSkills, addToast]);
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { AppErrorInput } from "../lib/desktop";
|
||||
|
||||
export interface AppActions {
|
||||
refresh: () => Promise<void>;
|
||||
setError: (error: string | null) => void;
|
||||
setError: (error: AppErrorInput) => void;
|
||||
openPathWithFeedback: (path: string) => Promise<void>;
|
||||
startRun: (taskId: string) => Promise<void>;
|
||||
cancelRun: (runId: string) => Promise<void>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { CompanyRecord, OnboardingBootstrapResult } from "@shared/types";
|
||||
import { useAppStore } from "../state/store";
|
||||
import { NEW_ENTITY_ID } from "../lib/constants";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { describeDesktopError, formatDesktopResultError, unwrap } from "../lib/desktop";
|
||||
import { useDesktopEvents } from "./useDesktopEvents";
|
||||
import { useDataLoader } from "./useDataLoader";
|
||||
import { useCompanyData } from "./useCompanyData";
|
||||
@@ -98,7 +98,7 @@ export function useCompanyController() {
|
||||
incrementLogRevision();
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().setError(result.error.message);
|
||||
useAppStore.getState().setError(formatDesktopResultError(result.error));
|
||||
})();
|
||||
}, [activeRunId, companyData.runs, incrementLogRevision]);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useCompanyController() {
|
||||
clearCompanyScopedUiState();
|
||||
await refresh();
|
||||
store.setError(null);
|
||||
} catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
|
||||
} catch (err) { store.setError(describeDesktopError(err)); }
|
||||
}, [clearCompanyScopedUiState, refresh]);
|
||||
|
||||
const deleteCompany = useCallback(async (companyId: string) => {
|
||||
@@ -120,7 +120,7 @@ export function useCompanyController() {
|
||||
store.setConfirmDeleteCompanyId(null);
|
||||
store.setCompanySwitcherOpen(false);
|
||||
await refresh();
|
||||
} catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
|
||||
} catch (err) { store.setError(describeDesktopError(err)); }
|
||||
}, [refresh]);
|
||||
|
||||
const handleOnboardingDone = useCallback(async (summary: OnboardingBootstrapResult) => {
|
||||
@@ -136,7 +136,7 @@ export function useCompanyController() {
|
||||
const openPathWithFeedback = useCallback(async (path: string) => {
|
||||
const store = useAppStore.getState();
|
||||
try { unwrap(await window.agentCompany.openPath(path)); store.setError(null); }
|
||||
catch (err) { store.setError(err instanceof Error ? err.message : String(err)); }
|
||||
catch (err) { store.setError(describeDesktopError(err)); }
|
||||
}, []);
|
||||
|
||||
// HOOK-002 fix: return a STABLE useMemo'd object. Deps are each primitive
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { ProfileSnapshot, SectionId } from "@shared/types";
|
||||
import { unwrap } from "../lib/desktop";
|
||||
import { describeDesktopError, unwrap } from "../lib/desktop";
|
||||
import { useAppStore } from "../state/store";
|
||||
|
||||
export function useDataLoader(
|
||||
@@ -32,7 +32,7 @@ export function useDataLoader(
|
||||
}
|
||||
store.setError(null);
|
||||
} catch (nextError) {
|
||||
store.setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||
store.setError(describeDesktopError(nextError));
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
|
||||
export interface DesktopErrorEnvelope {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AppErrorNotice {
|
||||
code: string;
|
||||
message: string;
|
||||
actionHint: string;
|
||||
}
|
||||
|
||||
export type AppErrorInput =
|
||||
| AppErrorNotice
|
||||
| DesktopErrorEnvelope
|
||||
| Error
|
||||
| string
|
||||
| null;
|
||||
|
||||
const GENERIC_ERROR_CODE = "APP_ERROR";
|
||||
const ACTION_REQUIRED_CODE = "ACTION_REQUIRED";
|
||||
const DEFAULT_ACTION_HINT = "Use this code when reporting the issue.";
|
||||
|
||||
const ACTION_HINTS_BY_CODE: Record<string, string> = {
|
||||
ACTION_REQUIRED: "Complete the required selection, then retry the action.",
|
||||
AGENT_ALREADY_RUNNING: "Open Runs, review the active run, then retry from the task.",
|
||||
AGENT_PENDING_APPROVAL: "Open Approvals, resolve the hire request, then retry the task.",
|
||||
AGENT_NOT_RUNNABLE: "Open Agents, activate the assigned agent, then retry the task.",
|
||||
APP_SHUTTING_DOWN: "Restart AgentCompany after shutdown completes.",
|
||||
BUDGET_POLICY_BLOCKED: "Open Budget, update the blocking policy, then retry the run.",
|
||||
CHECKOUT_RACE_LOST: "Refresh tasks, review the current assignee, then retry the run.",
|
||||
COMPANY_NOT_ACTIVE: "Select the active company, then retry the action.",
|
||||
COMPANY_NOT_FOUND: "Refresh companies, select an existing company, then retry the action.",
|
||||
CONNECTOR_MODEL_NOT_ALLOWED: "Open Connectors, enable an allowed model, then retry the run.",
|
||||
CONNECTOR_NOT_FOUND: "Open Connectors, select an available connector, then retry the action.",
|
||||
CONNECTOR_NOT_READY: "Open Connectors, complete the credential setup, then retry the run.",
|
||||
INVALID_COMPANY_REFERENCE: "Refresh the company data, then retry with a current selection.",
|
||||
INVALID_INPUT: "Review the entered fields, then retry the action.",
|
||||
INVALID_PAYLOAD: "Review the entered fields, then retry the action.",
|
||||
INVALID_TASK_STATE: "Refresh the task, review its current state, then retry the action.",
|
||||
MODEL_PICK_FAILED: "Open Connectors, choose an enabled model, then retry the run.",
|
||||
OPEN_PATH_FAILED: "Verify the path exists on disk, then open it again.",
|
||||
OPEN_PATH_FORBIDDEN: "Open a path inside the managed workspace.",
|
||||
RESTORE_BLOCKED: "Stop active runs, then restore the profile backup.",
|
||||
RUN_NOT_FOUND: "Refresh Runs, select an existing run, then retry the action.",
|
||||
SERVICE_NOT_READY: "Wait for startup to finish, then retry the action.",
|
||||
TASK_NOT_FOUND: "Refresh Tasks, select an existing task, then retry the action.",
|
||||
TASK_UNASSIGNED: "Assign an agent to the task, then start the run.",
|
||||
TASK_VALIDATION_FAILED: "Review the task fields, then save again.",
|
||||
WORKER_UNAVAILABLE: "Wait for the worker to become ready, then retry the run.",
|
||||
WORKSPACE_COMPANY_MISMATCH: "Select a workspace owned by the current company.",
|
||||
WORKSPACE_UNRESOLVED: "Open Workspaces, repair the selected workspace path, then retry the run.",
|
||||
};
|
||||
|
||||
export class DesktopError extends Error {
|
||||
constructor(public readonly code: string, message: string) {
|
||||
super(message);
|
||||
@@ -7,9 +60,111 @@ export class DesktopError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function createAppErrorNotice(
|
||||
message: string,
|
||||
code: string = ACTION_REQUIRED_CODE,
|
||||
actionHint?: string,
|
||||
): AppErrorNotice {
|
||||
return {
|
||||
code: normalizeCode(code),
|
||||
message: normalizeMessage(message),
|
||||
actionHint: actionHint?.trim() || actionHintForCode(code),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDesktopResultError(error: DesktopErrorEnvelope): AppErrorNotice {
|
||||
return createAppErrorNotice(error.message, error.code);
|
||||
}
|
||||
|
||||
export function describeDesktopError(error: unknown): AppErrorNotice {
|
||||
if (isAppErrorNotice(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof DesktopError) {
|
||||
return createAppErrorNotice(error.message, error.code);
|
||||
}
|
||||
|
||||
if (isDesktopErrorEnvelope(error)) {
|
||||
return formatDesktopResultError(error);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return createAppErrorNotice(error.message, GENERIC_ERROR_CODE);
|
||||
}
|
||||
|
||||
return createAppErrorNotice(String(error), GENERIC_ERROR_CODE);
|
||||
}
|
||||
|
||||
export function normalizeAppErrorNotice(error: AppErrorInput): AppErrorNotice | null {
|
||||
if (error === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return createAppErrorNotice(error);
|
||||
}
|
||||
return describeDesktopError(error);
|
||||
}
|
||||
|
||||
export function unwrap<T>(result: DesktopResult<T>): T {
|
||||
if (!result.ok) {
|
||||
throw new DesktopError(result.error.code, result.error.message);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function normalizeCode(code: string): string {
|
||||
const trimmed = code.trim();
|
||||
return trimmed.length > 0 ? trimmed : GENERIC_ERROR_CODE;
|
||||
}
|
||||
|
||||
function normalizeMessage(message: string): string {
|
||||
const trimmed = message.trim();
|
||||
return trimmed.length > 0 ? trimmed : "The action could not be completed.";
|
||||
}
|
||||
|
||||
function actionHintForCode(code: string): string {
|
||||
const normalized = normalizeCode(code);
|
||||
const exactHint = ACTION_HINTS_BY_CODE[normalized.toUpperCase()];
|
||||
if (exactHint) {
|
||||
return exactHint;
|
||||
}
|
||||
|
||||
const lowerCode = normalized.toLowerCase();
|
||||
if (lowerCode.includes("connector") || lowerCode.includes("model")) {
|
||||
return "Open Connectors, review the provider setup, then retry the action.";
|
||||
}
|
||||
if (lowerCode.includes("workspace") || lowerCode.includes("path")) {
|
||||
return "Open Workspaces, verify the path, then retry the action.";
|
||||
}
|
||||
if (lowerCode.includes("permission") || lowerCode.includes("forbidden") || lowerCode.includes("unauthorized")) {
|
||||
return "Review the account permissions, then retry the action.";
|
||||
}
|
||||
if (lowerCode.includes("validation") || lowerCode.includes("invalid")) {
|
||||
return "Review the entered fields, then retry the action.";
|
||||
}
|
||||
if (lowerCode.includes("not_found")) {
|
||||
return "Refresh the view, select an existing item, then retry the action.";
|
||||
}
|
||||
|
||||
return DEFAULT_ACTION_HINT;
|
||||
}
|
||||
|
||||
function isDesktopErrorEnvelope(error: unknown): error is DesktopErrorEnvelope {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
"message" in error &&
|
||||
typeof (error as { code?: unknown }).code === "string" &&
|
||||
typeof (error as { message?: unknown }).message === "string",
|
||||
);
|
||||
}
|
||||
|
||||
function isAppErrorNotice(error: unknown): error is AppErrorNotice {
|
||||
return Boolean(
|
||||
isDesktopErrorEnvelope(error) &&
|
||||
"actionHint" in error &&
|
||||
typeof (error as { actionHint?: unknown }).actionHint === "string",
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,13 @@ import type {
|
||||
StandupReport,
|
||||
TaskRecord,
|
||||
} from "@shared/types";
|
||||
import {
|
||||
describeDesktopError,
|
||||
formatDesktopResultError,
|
||||
normalizeAppErrorNotice,
|
||||
type AppErrorInput,
|
||||
type AppErrorNotice,
|
||||
} from "../../lib/desktop";
|
||||
|
||||
// Sidebar badge counts and inbox items both live in the renderer —
|
||||
// derived on every read from the snapshot (see `@shared/sidebar-badges`
|
||||
@@ -26,11 +33,11 @@ export interface CompanySlice {
|
||||
snapshotRevision: number;
|
||||
companyMetrics: CompanyMetrics | null;
|
||||
standupReport: StandupReport | null;
|
||||
error: string | null;
|
||||
error: AppErrorNotice | null;
|
||||
setSnapshot: (snapshot: ProfileSnapshot | null) => void;
|
||||
setCompanyMetrics: (metrics: CompanyMetrics | null) => void;
|
||||
setStandupReport: (report: StandupReport | null) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setError: (error: AppErrorInput) => void;
|
||||
refreshSnapshot: () => Promise<void>;
|
||||
patchTask: (task: TaskRecord) => void;
|
||||
removeTask: (taskId: string) => void;
|
||||
@@ -68,7 +75,7 @@ export const createCompanySlice: StateCreator<
|
||||
}),
|
||||
setError: (error) =>
|
||||
set((state) => {
|
||||
state.error = error;
|
||||
state.error = normalizeAppErrorNotice(error);
|
||||
}),
|
||||
refreshSnapshot: async () => {
|
||||
try {
|
||||
@@ -80,12 +87,12 @@ export const createCompanySlice: StateCreator<
|
||||
});
|
||||
} else {
|
||||
set((state) => {
|
||||
state.error = result.error.message;
|
||||
state.error = formatDesktopResultError(result.error);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
set((state) => {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
state.error = describeDesktopError(err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { StateCreator } from "zustand";
|
||||
import type { AppState } from "../store";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { DEFAULT_LOCALE } from "@shared/locale";
|
||||
import type { AppErrorNotice } from "../../lib/desktop";
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
message: string | AppErrorNotice;
|
||||
tone: "success" | "danger" | "warn";
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ export interface UiSlice {
|
||||
setTaskViewMode: (mode: "list" | "kanban") => void;
|
||||
setTaskInspectorTab: (tab: TaskInspectorTab) => void;
|
||||
incrementLogRevision: () => void;
|
||||
addToast: (message: string, tone?: Toast["tone"]) => void;
|
||||
addToast: (message: Toast["message"], tone?: Toast["tone"]) => void;
|
||||
dismissToast: (id: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { WarningCircle } from "../components/ui/icons";
|
||||
import type { SectionId } from "@shared/types";
|
||||
import type { AppErrorNotice } from "../lib/desktop";
|
||||
import { ErrorBoundary } from "../components/ui";
|
||||
import { OrganizationSections } from "./sections/OrganizationSections";
|
||||
import { ExecutionSections } from "./sections/ExecutionSections";
|
||||
@@ -8,7 +9,7 @@ import { OperationsSections } from "./sections/OperationsSections";
|
||||
|
||||
interface SectionContentViewProps {
|
||||
section: SectionId;
|
||||
error: string | null;
|
||||
error: AppErrorNotice | null;
|
||||
pageTransition: { duration: number; ease?: readonly [number, number, number, number] } | { duration: number };
|
||||
}
|
||||
|
||||
@@ -19,9 +20,20 @@ export function SectionContentView({ section, error, pageTransition }: SectionCo
|
||||
<div className={isCommunication ? "flex min-h-0 flex-1 flex-col" : "mx-auto flex w-full max-w-[1040px] flex-col px-6 py-6 lg:px-10 lg:py-8"}>
|
||||
|
||||
{error ? (
|
||||
<div className={`flex items-center gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-[13px] text-destructive ${isCommunication ? "mx-6 mt-4" : "mb-6"}`}>
|
||||
<WarningCircle size={18} />
|
||||
<span>{error}</span>
|
||||
<div
|
||||
role="alert"
|
||||
className={`flex items-start gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-destructive ${isCommunication ? "mx-6 mt-4" : "mb-6"}`}
|
||||
>
|
||||
<WarningCircle size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="rounded bg-background/80 px-1.5 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.08em]">
|
||||
{error.code}
|
||||
</span>
|
||||
<span className="min-w-0 text-[13px] font-semibold leading-5">{error.actionHint}</span>
|
||||
</div>
|
||||
<div className="mt-1 break-words text-[12px] leading-5 text-destructive/80">{error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SprintManager } from "../../components/company-operations/SprintManager
|
||||
import { ActionButton, EmptyState, Modal } from "../../components/ui";
|
||||
import { Plus } from "../../components/ui/icons";
|
||||
import { useT } from "../../i18n";
|
||||
import { unwrap } from "../../lib/desktop";
|
||||
import { describeDesktopError, unwrap } from "../../lib/desktop";
|
||||
import { useAppStore } from "../../state/store";
|
||||
import { useSectionContext } from "../../hooks/useSectionContext";
|
||||
import { useAppActions } from "../../hooks/useAppActions";
|
||||
@@ -135,7 +135,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
unwrap(await window.agentCompany.triggerBrowserLogin({ companyId: snapshot.currentCompanyId, socialAccountId: accountId }));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
@@ -145,7 +145,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
}}
|
||||
onLogin={async (accountId) => {
|
||||
@@ -155,7 +155,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
}}
|
||||
onOpenPath={async (path) => {
|
||||
@@ -163,7 +163,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
unwrap(await window.agentCompany.openPath(path));
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
}}
|
||||
onCancelAction={async (actionId) => {
|
||||
@@ -173,7 +173,7 @@ export function OperationsSections({ section }: OperationsSectionsProps) {
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : String(error));
|
||||
setError(describeDesktopError(error));
|
||||
}
|
||||
}}
|
||||
onNavigate={(sectionName, entityId) => {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DesktopResult } from "@shared/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createAppErrorNotice,
|
||||
describeDesktopError,
|
||||
DesktopError,
|
||||
formatDesktopResultError,
|
||||
normalizeAppErrorNotice,
|
||||
unwrap,
|
||||
} from "../../src/renderer/lib/desktop";
|
||||
|
||||
describe("renderer desktop error notices", () => {
|
||||
it("keeps DesktopResult error codes when unwrap throws", () => {
|
||||
const result: DesktopResult<string> = {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled.",
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => unwrap(result)).toThrow(DesktopError);
|
||||
|
||||
try {
|
||||
unwrap(result);
|
||||
throw new Error("unwrap should have thrown");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DesktopError);
|
||||
expect((error as DesktopError).code).toBe("CONNECTOR_MODEL_NOT_ALLOWED");
|
||||
expect((error as DesktopError).message).toBe("The assigned agent model is disabled.");
|
||||
}
|
||||
});
|
||||
|
||||
it("turns IPC error envelopes into actionable notices", () => {
|
||||
const notice = formatDesktopResultError({
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled or invalid for its connector.",
|
||||
});
|
||||
|
||||
expect(notice).toEqual({
|
||||
code: "CONNECTOR_MODEL_NOT_ALLOWED",
|
||||
message: "The assigned agent model is disabled or invalid for its connector.",
|
||||
actionHint: "Open Connectors, enable an allowed model, then retry the run.",
|
||||
});
|
||||
});
|
||||
|
||||
it("describes thrown DesktopError instances with their original code", () => {
|
||||
const notice = describeDesktopError(new DesktopError("TASK_UNASSIGNED", "Assign an agent before starting a run."));
|
||||
|
||||
expect(notice).toEqual({
|
||||
code: "TASK_UNASSIGNED",
|
||||
message: "Assign an agent before starting a run.",
|
||||
actionHint: "Assign an agent to the task, then start the run.",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes local string errors into action-required notices", () => {
|
||||
expect(normalizeAppErrorNotice("Select a company before starting a run.")).toEqual({
|
||||
code: "ACTION_REQUIRED",
|
||||
message: "Select a company before starting a run.",
|
||||
actionHint: "Complete the required selection, then retry the action.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit local action hints", () => {
|
||||
expect(
|
||||
createAppErrorNotice(
|
||||
"Select a company before starting a run.",
|
||||
"COMPANY_REQUIRED",
|
||||
"Select a company in the sidebar, then start the run.",
|
||||
),
|
||||
).toEqual({
|
||||
code: "COMPANY_REQUIRED",
|
||||
message: "Select a company before starting a run.",
|
||||
actionHint: "Select a company in the sidebar, then start the run.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user