feat(hands): restructure Hands UI with Chinese localization

Major changes:
- Add HandList.tsx component for left sidebar
- Add HandTaskPanel.tsx for middle content area
- Restructure Sidebar tabs: 分身/HANDS/Workflow
- Remove Hands tab from RightPanel
- Localize all UI text to Chinese
- Archive legacy OpenClaw documentation
- Add Hands integration lessons document
- Update feature checklist with new components

UI improvements:
- Left sidebar now shows Hands list with status icons
- Middle area shows selected Hand's tasks and results
- Consistent styling with Tailwind CSS
- Chinese status labels and buttons

Documentation:
- Create docs/archive/openclaw-legacy/ for old docs
- Add docs/knowledge-base/hands-integration-lessons.md
- Update docs/knowledge-base/feature-checklist.md
- Update docs/knowledge-base/README.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-14 23:16:32 +08:00
parent 67e1da635d
commit 07079293f4
126 changed files with 36229 additions and 1035 deletions

View File

@@ -1,5 +1,7 @@
import { create } from 'zustand';
import { GatewayClient, ConnectionState, getGatewayClient } from '../lib/gateway-client';
import { create } from 'zustand';
import { DEFAULT_GATEWAY_URL, FALLBACK_GATEWAY_URLS, GatewayClient, ConnectionState, getGatewayClient, getLocalDeviceIdentity, getStoredGatewayToken, getStoredGatewayUrl, setStoredGatewayToken, setStoredGatewayUrl } from '../lib/gateway-client';
import { approveLocalGatewayDevicePairing, getLocalGatewayAuth, getLocalGatewayStatus, getUnsupportedLocalGatewayStatus, isTauriRuntime, prepareLocalGatewayForTauri, restartLocalGateway as restartLocalGatewayCommand, startLocalGateway as startLocalGatewayCommand, stopLocalGateway as stopLocalGatewayCommand, type LocalGatewayStatus } from '../lib/tauri-gateway';
import { useChatStore } from './chatStore';
interface GatewayLog {
timestamp: number;
@@ -14,7 +16,16 @@ interface Clone {
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
workspaceResolvedPath?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
createdAt: string;
bootstrapReady?: boolean;
bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>;
updatedAt?: string;
}
interface UsageStats {
@@ -43,12 +54,218 @@ interface ScheduledTask {
description?: string;
}
interface SkillInfo {
id: string;
name: string;
path: string;
source: 'builtin' | 'extra';
}
interface QuickConfig {
agentName?: string;
agentRole?: string;
userName?: string;
userRole?: string;
agentNickname?: string;
scenarios?: string[];
workspaceDir?: string;
gatewayUrl?: string;
gatewayToken?: string;
skillsExtraDirs?: string[];
mcpServices?: Array<{ id: string; name: string; enabled: boolean }>;
theme?: 'light' | 'dark';
autoStart?: boolean;
showToolCalls?: boolean;
restrictFiles?: boolean;
autoSaveContext?: boolean;
fileWatching?: boolean;
privacyOptIn?: boolean;
}
interface WorkspaceInfo {
path: string;
resolvedPath: string;
exists: boolean;
fileCount: number;
totalSize: number;
}
// === OpenFang Types ===
export interface HandRequirement {
description: string;
met: boolean;
details?: string;
}
export interface Hand {
id: string; // Hand ID used for API calls
name: string; // Display name
description: string;
status: 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
currentRunId?: string;
requirements_met?: boolean;
category?: string; // productivity, data, content, communication
icon?: string;
// Extended fields from details API
provider?: string;
model?: string;
requirements?: HandRequirement[];
tools?: string[];
metrics?: string[];
toolCount?: number;
metricCount?: number;
}
export interface HandRun {
runId: string;
status: string;
result?: unknown;
}
export interface Workflow {
id: string;
name: string;
steps: number;
description?: string;
}
export interface WorkflowRun {
runId: string;
status: string;
step?: string;
result?: unknown;
}
export interface Trigger {
id: string;
type: string;
enabled: boolean;
}
// === Scheduler Types ===
export interface ScheduledJob {
id: string;
name: string;
cron: string;
enabled: boolean;
handName?: string;
workflowId?: string;
lastRun?: string;
nextRun?: string;
}
export interface EventTrigger {
id: string;
name: string;
eventType: string;
enabled: boolean;
handName?: string;
workflowId?: string;
}
export interface RunHistoryEntry {
id: string;
type: 'scheduled_job' | 'event_trigger';
sourceId: string;
sourceName: string;
status: 'success' | 'failure' | 'running';
startedAt: string;
completedAt?: string;
duration?: number;
error?: string;
}
// === Approval Types ===
export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'expired';
export interface Approval {
id: string;
handName: string;
runId?: string;
status: ApprovalStatus;
requestedAt: string;
requestedBy?: string;
reason?: string;
action?: string;
params?: Record<string, unknown>;
respondedAt?: string;
respondedBy?: string;
responseReason?: string;
}
export interface AuditLogEntry {
id: string;
timestamp: string;
action: string;
actor?: string;
result?: 'success' | 'failure';
details?: Record<string, unknown>;
}
// === Security Types ===
export interface SecurityLayer {
name: string;
enabled: boolean;
description?: string;
}
export interface SecurityStatus {
layers: SecurityLayer[];
enabledCount: number;
totalCount: number;
securityLevel: 'critical' | 'high' | 'medium' | 'low';
}
function shouldRetryGatewayCandidate(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return (
message === 'WebSocket connection failed'
|| message.startsWith('Gateway handshake timed out')
|| message.startsWith('WebSocket closed before handshake completed')
);
}
function requiresLocalDevicePairing(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error || '');
return message.includes('pairing required');
}
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
if (totalCount === 0) return 'low';
const ratio = enabledCount / totalCount;
if (ratio >= 0.875) return 'critical'; // 14-16 layers
if (ratio >= 0.625) return 'high'; // 10-13 layers
if (ratio >= 0.375) return 'medium'; // 6-9 layers
return 'low'; // 0-5 layers
}
function isLoopbackGatewayUrl(url: string): boolean {
return /^wss?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(url.trim());
}
async function approveCurrentLocalDevicePairing(url: string): Promise<boolean> {
if (!isTauriRuntime() || !isLoopbackGatewayUrl(url)) {
return false;
}
const identity = await getLocalDeviceIdentity();
const result = await approveLocalGatewayDevicePairing(identity.deviceId, identity.publicKeyBase64, url);
return result.approved;
}
interface GatewayStore {
// Connection state
connectionState: ConnectionState;
gatewayVersion: string | null;
error: string | null;
logs: GatewayLog[];
localGateway: LocalGatewayStatus;
localGatewayBusy: boolean;
isLoading: boolean;
// Data
clones: Clone[];
@@ -56,6 +273,17 @@ interface GatewayStore {
pluginStatus: any[];
channels: ChannelInfo[];
scheduledTasks: ScheduledTask[];
skillsCatalog: SkillInfo[];
quickConfig: QuickConfig;
workspaceInfo: WorkspaceInfo | null;
// OpenFang Data
hands: Hand[];
workflows: Workflow[];
triggers: Trigger[];
auditLogs: AuditLogEntry[];
securityStatus: SecurityStatus | null;
approvals: Approval[];
// Client reference
client: GatewayClient;
@@ -65,13 +293,62 @@ interface GatewayStore {
disconnect: () => void;
sendMessage: (message: string, sessionKey?: string) => Promise<{ runId: string }>;
loadClones: () => Promise<void>;
createClone: (opts: { name: string; role?: string; scenarios?: string[] }) => Promise<void>;
createClone: (opts: {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
}) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
loadPluginStatus: () => Promise<void>;
loadChannels: () => Promise<void>;
loadScheduledTasks: () => Promise<void>;
loadSkillsCatalog: () => Promise<void>;
loadQuickConfig: () => Promise<void>;
saveQuickConfig: (updates: Partial<QuickConfig>) => Promise<void>;
loadWorkspaceInfo: () => Promise<void>;
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
clearLogs: () => void;
// OpenFang Actions
loadHands: () => Promise<void>;
getHandDetails: (name: string) => Promise<Hand | undefined>;
triggerHand: (name: string, params?: Record<string, unknown>) => Promise<HandRun | undefined>;
approveHand: (name: string, runId: string, approved: boolean, reason?: string) => Promise<void>;
cancelHand: (name: string, runId: string) => Promise<void>;
loadWorkflows: () => Promise<void>;
executeWorkflow: (id: string, input?: Record<string, unknown>) => Promise<WorkflowRun | undefined>;
cancelWorkflow: (id: string, runId: string) => Promise<void>;
loadTriggers: () => Promise<void>;
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
loadSecurityStatus: () => Promise<void>;
loadApprovals: (status?: ApprovalStatus) => Promise<void>;
respondToApproval: (approvalId: string, approved: boolean, reason?: string) => Promise<void>;
}
function normalizeGatewayUrlCandidate(url: string): string {
return url.trim().replace(/\/+$/, '');
}
function getLocalGatewayConnectUrl(status: LocalGatewayStatus): string | null {
if (status.probeUrl && status.probeUrl.trim()) {
return normalizeGatewayUrlCandidate(status.probeUrl);
}
if (status.port) {
return `ws://127.0.0.1:${status.port}`;
}
return null;
}
export const useGatewayStore = create<GatewayStore>((set, get) => {
@@ -93,24 +370,146 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
gatewayVersion: null,
error: null,
logs: [],
localGateway: getUnsupportedLocalGatewayStatus(),
localGatewayBusy: false,
isLoading: false,
clones: [],
usageStats: null,
pluginStatus: [],
channels: [],
scheduledTasks: [],
skillsCatalog: [],
quickConfig: {},
workspaceInfo: null,
// OpenFang state
hands: [],
workflows: [],
triggers: [],
auditLogs: [],
securityStatus: null,
approvals: [],
client,
connect: async (url?: string, token?: string) => {
const c = get().client;
const resolveCandidates = async (): Promise<string[]> => {
const explicitUrl = url?.trim();
if (explicitUrl) {
return [normalizeGatewayUrlCandidate(explicitUrl)];
}
const candidates: string[] = [];
if (isTauriRuntime()) {
try {
const localStatus = await getLocalGatewayStatus();
const localUrl = getLocalGatewayConnectUrl(localStatus);
if (localUrl) {
candidates.push(localUrl);
}
} catch {
/* ignore local gateway lookup failures during candidate selection */
}
}
const quickConfigGatewayUrl = get().quickConfig.gatewayUrl?.trim();
if (quickConfigGatewayUrl) {
candidates.push(quickConfigGatewayUrl);
}
candidates.push(getStoredGatewayUrl(), DEFAULT_GATEWAY_URL, ...FALLBACK_GATEWAY_URLS);
return Array.from(
new Set(
candidates
.filter(Boolean)
.map(normalizeGatewayUrlCandidate)
)
);
};
try {
set({ error: null });
const c = url ? getGatewayClient({ url, token }) : get().client;
await c.connect();
if (isTauriRuntime()) {
try {
await prepareLocalGatewayForTauri();
} catch {
/* ignore local gateway preparation failures during connection bootstrap */
}
}
// Use the first non-empty token from: param > quickConfig > localStorage
let effectiveToken = token || get().quickConfig.gatewayToken || getStoredGatewayToken();
if (!effectiveToken && isTauriRuntime()) {
try {
const localAuth = await getLocalGatewayAuth();
if (localAuth.gatewayToken) {
effectiveToken = localAuth.gatewayToken;
setStoredGatewayToken(localAuth.gatewayToken);
}
} catch {
/* ignore local auth lookup failures during connection bootstrap */
}
}
console.log('[GatewayStore] Connecting with token:', effectiveToken ? `${effectiveToken.substring(0, 8)}...` : '(empty)');
const candidateUrls = await resolveCandidates();
let lastError: unknown = null;
let connectedUrl: string | null = null;
for (const candidateUrl of candidateUrls) {
try {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
} catch (err) {
lastError = err;
if (requiresLocalDevicePairing(err)) {
const approved = await approveCurrentLocalDevicePairing(candidateUrl);
if (approved) {
c.updateOptions({
url: candidateUrl,
token: effectiveToken,
});
await c.connect();
connectedUrl = candidateUrl;
break;
}
}
if (!shouldRetryGatewayCandidate(err)) {
throw err;
}
}
}
if (!connectedUrl) {
throw (lastError instanceof Error ? lastError : new Error('无法连接到任何可用 Gateway'));
}
setStoredGatewayUrl(connectedUrl);
// Fetch initial data after connection
try {
const health = await c.health();
set({ gatewayVersion: health?.version });
} catch { /* health may not return version */ }
await Promise.allSettled([
get().loadQuickConfig(),
get().loadWorkspaceInfo(),
get().loadClones(),
get().loadUsageStats(),
get().loadPluginStatus(),
get().loadScheduledTasks(),
get().loadSkillsCatalog(),
// OpenFang data loading
get().loadHands(),
get().loadWorkflows(),
get().loadTriggers(),
get().loadSecurityStatus(),
]);
await get().loadChannels();
} catch (err: any) {
set({ error: err.message });
throw err;
@@ -129,16 +528,42 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
loadClones: async () => {
try {
const result = await get().client.listClones();
set({ clones: result?.clones || [] });
const clones = result?.clones || result?.agents || [];
set({ clones });
useChatStore.getState().syncAgents(clones);
// Set default agent ID if we have agents and none is set
if (clones.length > 0 && clones[0].id) {
const client = get().client;
const currentDefault = client.getDefaultAgentId();
// Only set if the default doesn't exist in the list
const defaultExists = clones.some((c: any) => c.id === currentDefault);
if (!defaultExists) {
client.setDefaultAgentId(clones[0].id);
}
}
} catch { /* ignore if method not available */ }
},
createClone: async (opts) => {
try {
await get().client.createClone(opts);
const result = await get().client.createClone(opts);
await get().loadClones();
return result?.clone;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
updateClone: async (id, updates) => {
try {
const result = await get().client.updateClone(id, updates);
await get().loadClones();
return result?.clone;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
@@ -212,6 +637,345 @@ export const useGatewayStore = create<GatewayStore>((set, get) => {
} catch { /* ignore if heartbeat.tasks not available */ }
},
loadSkillsCatalog: async () => {
try {
const result = await get().client.listSkills();
set({ skillsCatalog: result?.skills || [] });
if (result?.extraDirs) {
set((state) => ({
quickConfig: {
...state.quickConfig,
skillsExtraDirs: result.extraDirs,
},
}));
}
} catch { /* ignore if skills list not available */ }
},
loadQuickConfig: async () => {
try {
const result = await get().client.getQuickConfig();
set({ quickConfig: result?.quickConfig || {} });
} catch { /* ignore if quick config not available */ }
},
saveQuickConfig: async (updates) => {
try {
const nextConfig = { ...get().quickConfig, ...updates };
if (nextConfig.gatewayUrl) {
setStoredGatewayUrl(nextConfig.gatewayUrl);
}
if (Object.prototype.hasOwnProperty.call(updates, 'gatewayToken')) {
setStoredGatewayToken(nextConfig.gatewayToken || '');
}
const result = await get().client.saveQuickConfig(nextConfig);
set({ quickConfig: result?.quickConfig || nextConfig });
} catch (err: any) {
set({ error: err.message });
}
},
loadWorkspaceInfo: async () => {
try {
const info = await get().client.getWorkspaceInfo();
set({ workspaceInfo: info });
} catch { /* ignore if workspace info not available */ }
},
refreshLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true });
try {
const status = await getLocalGatewayStatus();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '读取本地 Gateway 状态失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return nextStatus;
}
},
startLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await startLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '启动本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
stopLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await stopLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '停止本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
restartLocalGateway: async () => {
if (!isTauriRuntime()) {
const unsupported = getUnsupportedLocalGatewayStatus();
set({ localGateway: unsupported, localGatewayBusy: false });
return unsupported;
}
set({ localGatewayBusy: true, error: null });
try {
const status = await restartLocalGatewayCommand();
set({ localGateway: status, localGatewayBusy: false });
return status;
} catch (err: any) {
const message = err?.message || '重启本地 Gateway 失败';
const nextStatus = {
...get().localGateway,
supported: true,
error: message,
};
set({ localGateway: nextStatus, localGatewayBusy: false, error: message });
return undefined;
}
},
// === OpenFang Actions ===
loadHands: async () => {
set({ isLoading: true });
try {
const result = await get().client.listHands();
// Map API response to Hand interface
const hands: Hand[] = (result?.hands || []).map(h => ({
id: h.id || h.name,
name: h.name,
description: h.description || '',
status: h.status || (h.requirements_met ? 'idle' : 'setup_needed'),
requirements_met: h.requirements_met,
category: h.category,
icon: h.icon,
toolCount: h.tool_count || h.tools?.length,
metricCount: h.metric_count || h.metrics?.length,
}));
set({ hands, isLoading: false });
} catch {
set({ isLoading: false });
/* ignore if hands API not available */
}
},
getHandDetails: async (name: string) => {
try {
const result = await get().client.getHand(name);
if (!result) return undefined;
// Map API response to extended Hand interface
const hand: Hand = {
id: result.id || result.name || name,
name: result.name || name,
description: result.description || '',
status: result.status || (result.requirements_met ? 'idle' : 'setup_needed'),
requirements_met: result.requirements_met,
category: result.category,
icon: result.icon,
provider: result.provider || result.config?.provider,
model: result.model || result.config?.model,
requirements: result.requirements?.map((r: any) => ({
description: r.description || r.name || String(r),
met: r.met ?? r.satisfied ?? true,
details: r.details || r.hint,
})),
tools: result.tools || result.config?.tools,
metrics: result.metrics || result.config?.metrics,
toolCount: result.tool_count || result.tools?.length || 0,
metricCount: result.metric_count || result.metrics?.length || 0,
};
// Update hands list with detailed info
set(state => ({
hands: state.hands.map(h => h.name === name ? { ...h, ...hand } : h),
}));
return hand;
} catch {
return undefined;
}
},
triggerHand: async (name: string, params?: Record<string, unknown>) => {
try {
const result = await get().client.triggerHand(name, params);
return result ? { runId: result.runId, status: result.status } : undefined;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => {
try {
await get().client.approveHand(name, runId, approved, reason);
// Refresh hands to update status
await get().loadHands();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
cancelHand: async (name: string, runId: string) => {
try {
await get().client.cancelHand(name, runId);
// Refresh hands to update status
await get().loadHands();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
loadWorkflows: async () => {
set({ isLoading: true });
try {
const result = await get().client.listWorkflows();
set({ workflows: result?.workflows || [], isLoading: false });
} catch {
set({ isLoading: false });
/* ignore if workflows API not available */
}
},
executeWorkflow: async (id: string, input?: Record<string, unknown>) => {
try {
const result = await get().client.executeWorkflow(id, input);
return result ? { runId: result.runId, status: result.status } : undefined;
} catch (err: any) {
set({ error: err.message });
return undefined;
}
},
cancelWorkflow: async (id: string, runId: string) => {
try {
await get().client.cancelWorkflow(id, runId);
// Refresh workflows to update status
await get().loadWorkflows();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
loadTriggers: async () => {
try {
const result = await get().client.listTriggers();
set({ triggers: result?.triggers || [] });
} catch { /* ignore if triggers API not available */ }
},
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
try {
const result = await get().client.getAuditLogs(opts);
set({ auditLogs: (result?.logs || []) as AuditLogEntry[] });
} catch { /* ignore if audit API not available */ }
},
loadSecurityStatus: async () => {
try {
const result = await get().client.getSecurityStatus();
if (result?.layers) {
const layers = result.layers as SecurityLayer[];
const enabledCount = layers.filter(l => l.enabled).length;
const totalCount = layers.length;
const securityLevel = calculateSecurityLevel(enabledCount, totalCount);
set({
securityStatus: {
layers,
enabledCount,
totalCount,
securityLevel,
},
});
}
} catch { /* ignore if security API not available */ }
},
loadApprovals: async (status?: ApprovalStatus) => {
try {
const result = await get().client.listApprovals(status);
const approvals: Approval[] = (result?.approvals || []).map((a: any) => ({
id: a.id || a.approval_id,
handName: a.hand_name || a.handName,
runId: a.run_id || a.runId,
status: a.status || 'pending',
requestedAt: a.requested_at || a.requestedAt || new Date().toISOString(),
requestedBy: a.requested_by || a.requestedBy,
reason: a.reason || a.description,
action: a.action || 'execute',
params: a.params,
respondedAt: a.responded_at || a.respondedAt,
respondedBy: a.responded_by || a.respondedBy,
responseReason: a.response_reason || a.responseReason,
}));
set({ approvals });
} catch { /* ignore if approvals API not available */ }
},
respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => {
try {
await get().client.respondToApproval(approvalId, approved, reason);
// Refresh approvals after response
await get().loadApprovals();
} catch (err: any) {
set({ error: err.message });
throw err;
}
},
clearLogs: () => set({ logs: [] }),
};
});