This commit is contained in:
ZacharyZhang-NY committed 2026-03-30 00:15:21 -04:00
1 parent 65e85135c1
commit 6f1de8047e
64 files changed
+1269 -515

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ win-unpacked
.blockmap .blockmap
.claude .claude
build build
.omc
# Test artifacts # Test artifacts
test-results/ test-results/
+23 -23
View File
@@ -1,25 +1,3 @@
x64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!assets{,/**/*}'
- '!build/release{,/**/*}'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
arm64: arm64:
firstOrDefaultFilePatterns: firstOrDefaultFilePatterns:
- '!**/node_modules/**' - '!**/node_modules/**'
@@ -42,5 +20,27 @@ arm64:
- package.json - package.json
- assets/icon.png - assets/icon.png
- assets/tray-icon*.png - assets/tray-icon*.png
x64:
firstOrDefaultFilePatterns:
- '!**/node_modules/**'
- '!assets{,/**/*}'
- '!build/release{,/**/*}'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
- '!**/*.{iml,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,suo,xproj,cc,d.ts,mk,a,o,obj,forge-meta,pdb}'
- '!**/._*'
- '!**/electron-builder.{yaml,yml,json,json5,toml,ts}'
- '!**/{.git,.hg,.svn,CVS,RCS,SCCS,__pycache__,.DS_Store,thumbs.db,.gitignore,.gitkeep,.gitattributes,.npmignore,.idea,.vs,.flowconfig,.jshintrc,.eslintrc,.circleci,.yarn-integrity,.yarn-metadata.json,yarn-error.log,yarn.lock,package-lock.json,npm-debug.log,pnpm-lock.yaml,bun.lock,bun.lockb,appveyor.yml,.travis.yml,circle.yml,.nyc_output,.husky,.github,electron-builder.env}'
- '!.yarn{,/**/*}'
- '!.editorconfig'
- '!.yarnrc.yml'
nodeModuleFilePatterns:
- '**/*'
- out/**
- package.json
- assets/icon.png
- assets/tray-icon*.png
nsis: nsis:
script: "!include \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\\StdUtils.nsh\"\n!addincludedir \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\"\n!macro _isUpdated _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"updated\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isUpdated `\"\" isUpdated \"\"`\n\n!macro _isForceRun _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"force-run\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForceRun `\"\" isForceRun \"\"`\n\n!macro _isKeepShortcuts _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"keep-shortcuts\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isKeepShortcuts `\"\" isKeepShortcuts \"\"`\n\n!macro _isNoDesktopShortcut _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"no-desktop-shortcut\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isNoDesktopShortcut `\"\" isNoDesktopShortcut \"\"`\n\n!macro _isDeleteAppData _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"delete-app-data\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isDeleteAppData `\"\" isDeleteAppData \"\"`\n\n!macro _isForAllUsers _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"allusers\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForAllUsers `\"\" isForAllUsers \"\"`\n\n!macro _isForCurrentUser _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"currentuser\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForCurrentUser `\"\" isForCurrentUser \"\"`\n\n!macro addLangs\n !insertmacro MUI_LANGUAGE \"English\"\n !insertmacro MUI_LANGUAGE \"German\"\n !insertmacro MUI_LANGUAGE \"French\"\n !insertmacro MUI_LANGUAGE \"SpanishInternational\"\n !insertmacro MUI_LANGUAGE \"SimpChinese\"\n !insertmacro MUI_LANGUAGE \"TradChinese\"\n !insertmacro MUI_LANGUAGE \"Japanese\"\n !insertmacro MUI_LANGUAGE \"Korean\"\n !insertmacro MUI_LANGUAGE \"Italian\"\n !insertmacro MUI_LANGUAGE \"Dutch\"\n !insertmacro MUI_LANGUAGE \"Danish\"\n !insertmacro MUI_LANGUAGE \"Swedish\"\n !insertmacro MUI_LANGUAGE \"Norwegian\"\n !insertmacro MUI_LANGUAGE \"Finnish\"\n !insertmacro MUI_LANGUAGE \"Russian\"\n !insertmacro MUI_LANGUAGE \"Portuguese\"\n !insertmacro MUI_LANGUAGE \"PortugueseBR\"\n !insertmacro MUI_LANGUAGE \"Polish\"\n !insertmacro MUI_LANGUAGE \"Ukrainian\"\n !insertmacro MUI_LANGUAGE \"Czech\"\n !insertmacro MUI_LANGUAGE \"Slovak\"\n !insertmacro MUI_LANGUAGE \"Hungarian\"\n !insertmacro MUI_LANGUAGE \"Arabic\"\n !insertmacro MUI_LANGUAGE \"Turkish\"\n !insertmacro MUI_LANGUAGE \"Thai\"\n !insertmacro MUI_LANGUAGE \"Vietnamese\"\n!macroend\n\n!addplugindir /x86-unicode \"C:\\Users\\zhang\\AppData\\Local\\electron-builder\\Cache\\nsis\\nsis-resources-3.4.1-nsis-resources-3.4.1\\plugins\\x86-unicode\"\n!include \"C:\\Users\\zhang\\AppData\\Local\\Temp\\t-GU2sm0\\6-messages.nsh\"\n\n!include \"common.nsh\"\n!include \"extractAppPackage.nsh\"\n\n# https://github.com/electron-userland/electron-builder/issues/3972#issuecomment-505171582\nCRCCheck off\nWindowIcon Off\nAutoCloseWindow True\nRequestExecutionLevel ${REQUEST_EXECUTION_LEVEL}\n\nFunction .onInit\n !ifndef SPLASH_IMAGE\n SetSilent silent\n !endif\n\n !insertmacro check64BitAndSetRegView\nFunctionEnd\n\nFunction .onGUIInit\n InitPluginsDir\n\n !ifdef SPLASH_IMAGE\n File /oname=$PLUGINSDIR\\splash.bmp \"${SPLASH_IMAGE}\"\n BgImage::SetBg $PLUGINSDIR\\splash.bmp\n BgImage::Redraw\n !endif\nFunctionEnd\n\nSection\n !ifdef SPLASH_IMAGE\n HideWindow\n !endif\n\n StrCpy $INSTDIR \"$PLUGINSDIR\\app\"\n !ifdef UNPACK_DIR_NAME\n StrCpy $INSTDIR \"$TEMP\\${UNPACK_DIR_NAME}\"\n !endif\n\n RMDir /r $INSTDIR\n SetOutPath $INSTDIR\n\n !ifdef APP_DIR_64\n !ifdef APP_DIR_ARM64\n !ifdef APP_DIR_32\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${elseif} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_64}\\*.*\"\n {endIf}\n !endif\n !else\n !ifdef APP_DIR_32\n ${if} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n File /r \"${APP_DIR_64}\\*.*\"\n !endif\n !endif\n !else\n !ifdef APP_DIR_32\n File /r \"${APP_DIR_32}\\*.*\"\n !else\n !insertmacro extractEmbeddedAppPackage\n !endif\n !endif\n\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_DIR\", \"$EXEDIR\").r0'\n System::CLine truncated script: "!include \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\\StdUtils.nsh\"\n!addincludedir \"C:\\Users\\zhang\\Documents\\GitHub\\AgentCompany\\node_modules\\.pnpm\\app-builder-lib@26.8.1_dmg-_62d31da0b96a3ea7b7d51a6bbe3b3b6e\\node_modules\\app-builder-lib\\templates\\nsis\\include\"\n!macro _isUpdated _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"updated\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isUpdated `\"\" isUpdated \"\"`\n\n!macro _isForceRun _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"force-run\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForceRun `\"\" isForceRun \"\"`\n\n!macro _isKeepShortcuts _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"keep-shortcuts\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isKeepShortcuts `\"\" isKeepShortcuts \"\"`\n\n!macro _isNoDesktopShortcut _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"no-desktop-shortcut\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isNoDesktopShortcut `\"\" isNoDesktopShortcut \"\"`\n\n!macro _isDeleteAppData _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"delete-app-data\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isDeleteAppData `\"\" isDeleteAppData \"\"`\n\n!macro _isForAllUsers _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"allusers\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForAllUsers `\"\" isForAllUsers \"\"`\n\n!macro _isForCurrentUser _a _b _t _f\n ${StdUtils.TestParameter} $R9 \"currentuser\"\n StrCmp \"$R9\" \"true\" `${_t}` `${_f}`\n!macroend\n!define isForCurrentUser `\"\" isForCurrentUser \"\"`\n\n!macro addLangs\n !insertmacro MUI_LANGUAGE \"English\"\n !insertmacro MUI_LANGUAGE \"German\"\n !insertmacro MUI_LANGUAGE \"French\"\n !insertmacro MUI_LANGUAGE \"SpanishInternational\"\n !insertmacro MUI_LANGUAGE \"SimpChinese\"\n !insertmacro MUI_LANGUAGE \"TradChinese\"\n !insertmacro MUI_LANGUAGE \"Japanese\"\n !insertmacro MUI_LANGUAGE \"Korean\"\n !insertmacro MUI_LANGUAGE \"Italian\"\n !insertmacro MUI_LANGUAGE \"Dutch\"\n !insertmacro MUI_LANGUAGE \"Danish\"\n !insertmacro MUI_LANGUAGE \"Swedish\"\n !insertmacro MUI_LANGUAGE \"Norwegian\"\n !insertmacro MUI_LANGUAGE \"Finnish\"\n !insertmacro MUI_LANGUAGE \"Russian\"\n !insertmacro MUI_LANGUAGE \"Portuguese\"\n !insertmacro MUI_LANGUAGE \"PortugueseBR\"\n !insertmacro MUI_LANGUAGE \"Polish\"\n !insertmacro MUI_LANGUAGE \"Ukrainian\"\n !insertmacro MUI_LANGUAGE \"Czech\"\n !insertmacro MUI_LANGUAGE \"Slovak\"\n !insertmacro MUI_LANGUAGE \"Hungarian\"\n !insertmacro MUI_LANGUAGE \"Arabic\"\n !insertmacro MUI_LANGUAGE \"Turkish\"\n !insertmacro MUI_LANGUAGE \"Thai\"\n !insertmacro MUI_LANGUAGE \"Vietnamese\"\n!macroend\n\n!addplugindir /x86-unicode \"C:\\Users\\zhang\\AppData\\Local\\electron-builder\\Cache\\nsis\\nsis-resources-3.4.1-nsis-resources-3.4.1\\plugins\\x86-unicode\"\n!include \"C:\\Users\\zhang\\AppData\\Local\\Temp\\t-i79qg1\\6-messages.nsh\"\n\n!include \"common.nsh\"\n!include \"extractAppPackage.nsh\"\n\n# https://github.com/electron-userland/electron-builder/issues/3972#issuecomment-505171582\nCRCCheck off\nWindowIcon Off\nAutoCloseWindow True\nRequestExecutionLevel ${REQUEST_EXECUTION_LEVEL}\n\nFunction .onInit\n !ifndef SPLASH_IMAGE\n SetSilent silent\n !endif\n\n !insertmacro check64BitAndSetRegView\nFunctionEnd\n\nFunction .onGUIInit\n InitPluginsDir\n\n !ifdef SPLASH_IMAGE\n File /oname=$PLUGINSDIR\\splash.bmp \"${SPLASH_IMAGE}\"\n BgImage::SetBg $PLUGINSDIR\\splash.bmp\n BgImage::Redraw\n !endif\nFunctionEnd\n\nSection\n !ifdef SPLASH_IMAGE\n HideWindow\n !endif\n\n StrCpy $INSTDIR \"$PLUGINSDIR\\app\"\n !ifdef UNPACK_DIR_NAME\n StrCpy $INSTDIR \"$TEMP\\${UNPACK_DIR_NAME}\"\n !endif\n\n RMDir /r $INSTDIR\n SetOutPath $INSTDIR\n\n !ifdef APP_DIR_64\n !ifdef APP_DIR_ARM64\n !ifdef APP_DIR_32\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${elseif} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n ${if} ${IsNativeARM64}\n File /r \"${APP_DIR_ARM64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_64}\\*.*\"\n {endIf}\n !endif\n !else\n !ifdef APP_DIR_32\n ${if} ${RunningX64}\n File /r \"${APP_DIR_64}\\*.*\"\n ${else}\n File /r \"${APP_DIR_32}\\*.*\"\n ${endIf}\n !else\n File /r \"${APP_DIR_64}\\*.*\"\n !endif\n !endif\n !else\n !ifdef APP_DIR_32\n File /r \"${APP_DIR_32}\\*.*\"\n !else\n !insertmacro extractEmbeddedAppPackage\n !endif\n !endif\n\n System::Call 'Kernel32::SetEnvironmentVariable(t, t)i (\"PORTABLE_EXECUTABLE_DIR\", \"$EXEDIR\").r0'\n System::CLine truncated
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agent-company", "name": "agent-company",
"version": "0.4.133", "version": "0.4.135",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.", "description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
+20 -1
View File
@@ -1955,6 +1955,24 @@ export class AppDatabase {
this.setSetting(`budget_reset_month_${companyId}`, month); this.setSetting(`budget_reset_month_${companyId}`, month);
} }
getDisabledModels(connectorId: string): Array<{ id: string; label: string }> {
const raw = this.getSetting(`disabled_models:${connectorId}`);
if (!raw) return [];
try {
return JSON.parse(raw);
} catch {
return raw.split(",").filter(Boolean).map((id) => ({ id, label: id }));
}
}
setDisabledModels(connectorId: string, models: Array<{ id: string; label: string }>) {
if (models.length === 0) {
this.db.prepare("delete from settings where key = ?").run(`disabled_models:${connectorId}`);
} else {
this.setSetting(`disabled_models:${connectorId}`, JSON.stringify(models));
}
}
listHeartbeatEligibleAgents(companyId: string): AgentRecord[] { listHeartbeatEligibleAgents(companyId: string): AgentRecord[] {
return this.readAll<AgentRecord>( return this.readAll<AgentRecord>(
"select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')", "select id, company_id as companyId, name, role, title, department, status, reports_to as reportsTo, connector_id as connectorId, workspace_id as workspaceId, model, capabilities, budget_monthly_usd as budgetMonthlyUsd, spent_monthly_usd as spentMonthlyUsd, metadata_json as metadataJson, heartbeat_enabled as heartbeatEnabled, heartbeat_interval_sec as heartbeatIntervalSec, last_heartbeat_at as lastHeartbeatAt, created_at as createdAt, updated_at as updatedAt from agents where company_id = ? and heartbeat_enabled = 1 and status not in ('terminated', 'paused', 'error', 'pending_approval')",
@@ -2351,7 +2369,8 @@ export class AppDatabase {
private seedConnectors() { private seedConnectors() {
// Remove connector rows for adapters that have been removed from the codebase. // Remove connector rows for adapters that have been removed from the codebase.
this.db.prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local')").run(); // Preserve native connectors and all claude_compat slots (user-configured API providers).
this.db.prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local') and id not like 'claude_compat%'").run();
const currentRows = this.readAll<Partial<ConnectorRecord>>( const currentRows = this.readAll<Partial<ConnectorRecord>>(
"select id, label, description, status, command, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors", "select id, label, description, status, command, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors",
); );
+8
View File
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
import { import {
IPC_CHANNELS, IPC_CHANNELS,
companyInputSchema, companyInputSchema,
connectorDisabledModelsSchema,
connectorInputSchema, connectorInputSchema,
deleteEntitySchema, deleteEntitySchema,
openTerminalCommandSchema, openTerminalCommandSchema,
@@ -98,6 +99,13 @@ export function registerCoreAdminHandlers({
return ok(true); return ok(true);
}); });
registerHandle(IPC_CHANNELS.setConnectorDisabledModels, async (_event, payload) => {
const parsed = connectorDisabledModelsSchema.parse(payload);
db.setDisabledModels(parsed.connectorId, parsed.disabledModels);
publishDomainChanged();
return ok(true);
});
registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => { registerHandle(IPC_CHANNELS.saveSecret, async (_event, payload) => {
const parsed = secretInputSchema.parse(payload); const parsed = secretInputSchema.parse(payload);
const companyError = ensureCompanyExists(parsed.companyId); const companyError = ensureCompanyExists(parsed.companyId);
+9
View File
@@ -109,6 +109,15 @@ export function registerCoreReadHandlers({
} }
}); });
registerHandle(IPC_CHANNELS.getConnectorDisabledModels, (_event, connectorId) => {
try {
const parsed = connectorInputSchema.shape.id.parse(connectorId);
return ok(db.getDisabledModels(parsed));
} catch {
return ok([]);
}
});
registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => { registerHandle(IPC_CHANNELS.getInbox, (_event, payload) => {
const companyId = z.string().uuid().parse(payload); const companyId = z.string().uuid().parse(payload);
const err = ensureCompanyExists(companyId); const err = ensureCompanyExists(companyId);
+6 -2
View File
@@ -148,15 +148,19 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
getApiPort: () => state.apiServer?.getPort() ?? 0, getApiPort: () => state.apiServer?.getPort() ?? 0,
notify: (options) => requireIpcSupport().notify(options), notify: (options) => requireIpcSupport().notify(options),
listConnectorModels: async (connectorId) => { listConnectorModels: async (connectorId) => {
const disabled = new Set(requireDb().getDisabledModels(connectorId).map((m) => m.id));
// For claude_compatible, return user-configured models from the DB // For claude_compatible, return user-configured models from the DB
// (stored as comma-separated values in the connector's model field) // (stored as comma-separated values in the connector's model field)
if (connectorId.startsWith("claude_compat")) { if (connectorId.startsWith("claude_compat")) {
const connector = requireDb().getConnector(connectorId); const connector = requireDb().getConnector(connectorId);
const raw = connector.model ?? ""; const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean).map((m) => ({ id: m, label: m })); return raw.split(",").map((m) => m.trim()).filter(Boolean)
.filter((m) => !disabled.has(m))
.map((m) => ({ id: m, label: m }));
} }
const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]); const definition = getConnectorDefinition(connectorId as Parameters<typeof getConnectorDefinition>[0]);
return definition.listModels ? definition.listModels() : []; const models = definition.listModels ? await definition.listModels() : [];
return models.filter((m) => !disabled.has(m.id));
}, },
}); });
+2
View File
@@ -40,6 +40,8 @@ const api: DesktopApi = {
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input), deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId), getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId),
listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId), listConnectorModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.listConnectorModels, connectorId),
getConnectorDisabledModels: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.getConnectorDisabledModels, connectorId),
setConnectorDisabledModels: (input) => ipcRenderer.invoke(IPC_CHANNELS.setConnectorDisabledModels, input),
openConnectorAuthTerminal: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.openConnectorAuthTerminal, connectorId), openConnectorAuthTerminal: (connectorId) => ipcRenderer.invoke(IPC_CHANNELS.openConnectorAuthTerminal, connectorId),
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input), addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input), listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input),
+32 -18
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { import {
CaretUpDown, CaretUpDown,
@@ -519,7 +519,7 @@ export function App() {
const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyKnowledge = useMemo(() => snapshot?.knowledgeBase?.filter((k) => !currentCompany || k.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companyMeetings = useMemo(() => snapshot?.meetings?.filter((m) => !currentCompany || m.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]); const companySprints = useMemo(() => snapshot?.sprints?.filter((s) => !currentCompany || s.companyId === currentCompany.id) ?? [], [snapshot, currentCompany]);
const paletteQuery = commandPaletteSearch.trim().toLowerCase(); const paletteQuery = useDeferredValue(commandPaletteSearch.trim().toLowerCase());
const paletteResults = useMemo(() => { const paletteResults = useMemo(() => {
const filteredNavItems = paletteQuery const filteredNavItems = paletteQuery
? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery)) ? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
@@ -581,6 +581,19 @@ export function App() {
} }
}, [runs, tasks]); }, [runs, tasks]);
// Global Cmd/Ctrl+K to open command palette
useEffect(() => {
function onGlobalKey(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
event.preventDefault();
setCommandPaletteOpen((prev) => !prev);
if (!commandPaletteOpen) { setCommandPaletteSearch(""); setSelectedPaletteIndex(0); }
}
}
document.addEventListener("keydown", onGlobalKey);
return () => document.removeEventListener("keydown", onGlobalKey);
}, [commandPaletteOpen]);
useEffect(() => { useEffect(() => {
if (!commandPaletteOpen) return; if (!commandPaletteOpen) return;
function onKey(event: KeyboardEvent) { function onKey(event: KeyboardEvent) {
@@ -741,13 +754,13 @@ export function App() {
<TranslationContext.Provider value={t}> <TranslationContext.Provider value={t}>
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background"> <div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
{showCustomTitleBar && <TitleBar />} {showCustomTitleBar && <TitleBar />}
<div className={`grid min-h-0 flex-1 overflow-hidden ${sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`}> <div className={`grid min-h-0 flex-1 overflow-hidden transition-[grid-template-columns] duration-200 ${sidebarCollapsed ? "grid-cols-[56px_minmax(0,1fr)]" : "grid-cols-[220px_minmax(0,1fr)]"}`} style={{ transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)" }}>
<aside className="flex h-full flex-col overflow-hidden border-r border-border bg-card"> <aside className="flex h-full flex-col overflow-hidden border-r border-border bg-card">
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<div className={`sidebar-drag flex items-center justify-center px-2 pb-3 ${isMac ? "pt-[38px]" : "pt-3"}`}> <div className={`sidebar-drag flex items-center justify-center px-2 pb-3 ${isMac ? "pt-[38px]" : "pt-3"}`}>
<button <button
aria-label="Open command palette" aria-label="Open command palette"
title="Command palette" title={`Command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground transition hover:bg-muted hover:text-foreground" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
> >
@@ -830,27 +843,28 @@ export function App() {
)} )}
</div> </div>
<button <button
aria-label="Open command palette" aria-label={`Open command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border border-border text-muted-foreground transition hover:bg-muted hover:text-foreground" title={`Command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex h-[30px] shrink-0 items-center justify-center gap-1 rounded-lg border border-border px-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
onClick={() => setCommandPaletteOpen(true)} onClick={() => setCommandPaletteOpen(true)}
> >
<Command size={14} /> <Command size={14} />
<kbd className="text-[10px] font-medium text-muted-foreground">{isMac ? "⌘K" : "^K"}</kbd>
</button> </button>
</div> </div>
)} )}
<nav aria-label="Main navigation" className={`flex-1 overflow-y-auto pb-3 ${sidebarCollapsed ? "px-1.5 pt-2" : "px-3 pt-2"}`}> <nav aria-label="Main navigation" className={`flex-1 overflow-y-auto pb-3 ${sidebarCollapsed ? "px-1.5 pt-2" : "px-3 pt-2"}`}>
{/* Core: Overview, Team, Work */}
<div className="space-y-0.5"> <div className="space-y-0.5">
{sidebarNav.map((item) => ( {sidebarNav.slice(0, 3).map((item) => (
<SectionButton <SectionButton key={item.id} active={section === item.id} label={item.label} icon={item.icon} onClick={() => setSection(item.id)} badge={navBadges[item.id]?.count} badgeTone={navBadges[item.id]?.tone} collapsed={sidebarCollapsed} />
key={item.id} ))}
active={section === item.id} </div>
label={item.label} <div className={`my-2 border-t border-border ${sidebarCollapsed ? "mx-1" : "mx-2"}`} />
icon={item.icon} {/* Collaborate & Extend: Communication, Skills, Documents, Approvals, Social */}
onClick={() => setSection(item.id)} <div className="space-y-0.5">
badge={navBadges[item.id]?.count} {sidebarNav.slice(3).map((item) => (
badgeTone={navBadges[item.id]?.tone} <SectionButton key={item.id} active={section === item.id} label={item.label} icon={item.icon} onClick={() => setSection(item.id)} badge={navBadges[item.id]?.count} badgeTone={navBadges[item.id]?.tone} collapsed={sidebarCollapsed} />
collapsed={sidebarCollapsed}
/>
))} ))}
</div> </div>
</nav> </nav>
@@ -899,7 +913,7 @@ export function App() {
<div className={section === "communication" ? "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"}> <div className={section === "communication" ? "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 ? ( {error ? (
<div className={`flex items-center gap-3 rounded-lg bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive ${section === "communication" ? "mx-6 mt-4" : "mb-6"}`}> <div className={`flex items-center gap-3 rounded-lg bg-destructive-muted px-4 py-3 text-[13px] text-destructive ${section === "communication" ? "mx-6 mt-4" : "mb-6"}`}>
<WarningCircle size={18} /> <WarningCircle size={18} />
<span>{error}</span> <span>{error}</span>
</div> </div>
@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { X } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react";
import type { AppLocale } from "@shared/locale"; import type { AppLocale } from "@shared/locale";
import { useFocusTrap } from "../ui";
interface ClaudeSettingsDialogProps { interface ClaudeSettingsDialogProps {
claudeApiKey: string; claudeApiKey: string;
@@ -26,17 +28,20 @@ export function ClaudeSettingsDialog({
saving, saving,
currentLocale, currentLocale,
}: ClaudeSettingsDialogProps) { }: ClaudeSettingsDialogProps) {
const trapRef = useFocusTrap(true);
useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [onClose]);
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/72 backdrop-blur-sm" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/72 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-[420px] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}> <div ref={trapRef} role="dialog" aria-modal="true" aria-label={currentLocale === "zh" ? "Claude Code 设置" : "Claude Code Settings"} className="w-full max-w-[min(420px,90vw)] rounded-lg border border-border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<h3 className="text-[16px] font-semibold text-foreground">Claude Code {currentLocale === "zh" ? "设置" : "Settings"}</h3> <h3 className="text-[16px] font-semibold text-foreground">Claude Code {currentLocale === "zh" ? "设置" : "Settings"}</h3>
<button type="button" onClick={onClose} className="rounded-lg p-1 text-muted-foreground transition hover:text-foreground"><X size={16} /></button> <button type="button" onClick={onClose} className="rounded-lg p-1 text-muted-foreground transition hover:text-foreground"><X size={16} /></button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label> <label htmlFor="claude-api-key" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">API Key</label>
<input <input
id="claude-api-key"
type="password" type="password"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeApiKey} value={claudeApiKey}
@@ -46,8 +51,9 @@ export function ClaudeSettingsDialog({
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}</div> <div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用订阅认证" : "Leave empty to use subscription auth"}</div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label> <label htmlFor="claude-base-url" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Base URL</label>
<input <input
id="claude-base-url"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeBaseUrl} value={claudeBaseUrl}
onChange={(e) => setClaudeBaseUrl(e.target.value)} onChange={(e) => setClaudeBaseUrl(e.target.value)}
@@ -56,8 +62,9 @@ export function ClaudeSettingsDialog({
<div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}</div> <div className="mt-1 text-[11px] text-muted-foreground">{currentLocale === "zh" ? "留空使用默认端点" : "Leave empty for default endpoint"}</div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label> <label htmlFor="claude-model" className="mb-1.5 block text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Model</label>
<input <input
id="claude-model"
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground"
value={claudeModel} value={claudeModel}
onChange={(e) => setClaudeModel(e.target.value)} onChange={(e) => setClaudeModel(e.target.value)}
@@ -162,7 +162,7 @@ export function CompanyStep({
</div> </div>
{error ? ( {error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive"> <div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -39,11 +39,11 @@ export function ConnectorReadiness({
{isChecking ? ( {isChecking ? (
<span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-[color:var(--muted-foreground)] border-t-[color:var(--primary)]" /> <span className="mt-0.5 h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-[color:var(--muted-foreground)] border-t-[color:var(--primary)]" />
) : isReady ? ( ) : isReady ? (
<CheckCircle size={16} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" /> <CheckCircle size={16} weight="fill" className="shrink-0 text-success" />
) : isMissing ? ( ) : isMissing ? (
<XCircle size={16} weight="fill" className="shrink-0 text-muted-foreground" /> <XCircle size={16} weight="fill" className="shrink-0 text-muted-foreground" />
) : ( ) : (
<WarningCircle size={16} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" /> <WarningCircle size={16} weight="fill" className="shrink-0 text-warning" />
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -75,7 +75,7 @@ export function ConnectorReadiness({
})} })}
</div> </div>
{!hasRunnableConnector ? ( {!hasRunnableConnector ? (
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-[12px] text-yellow-600 dark:text-yellow-400"> <div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3 text-[12px] text-warning">
{t("onboarding.connectorWarning")} {t("onboarding.connectorWarning")}
</div> </div>
) : null} ) : null}
@@ -28,7 +28,7 @@ export function LaunchStep({
transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.36, ease: [0.16, 1, 0.3, 1] }}
className="bg-card border border-border shadow-sm w-full rounded-lg p-7" className="bg-card border border-border shadow-sm w-full rounded-lg p-7"
> >
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-success-muted text-success">
<CheckCircle size={24} weight="fill" /> <CheckCircle size={24} weight="fill" />
</div> </div>
<h2 className="mt-5 text-[28px] tracking-[-0.03em] text-foreground"> <h2 className="mt-5 text-[28px] tracking-[-0.03em] text-foreground">
@@ -72,7 +72,7 @@ export function LaunchStep({
</div> </div>
{error ? ( {error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive"> <div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -70,7 +70,7 @@ export function MissionStep({
</div> </div>
{error ? ( {error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive"> <div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
@@ -80,7 +80,7 @@ export function NodeCheckStep({
{checking ? ( {checking ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
) : nodeStatus?.installed ? ( ) : nodeStatus?.installed ? (
<CheckCircle size={18} weight="fill" className="shrink-0 text-green-600 dark:text-green-400" /> <CheckCircle size={18} weight="fill" className="shrink-0 text-success" />
) : nodeStatus ? ( ) : nodeStatus ? (
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" /> <XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
) : null} ) : null}
@@ -88,7 +88,7 @@ export function NodeCheckStep({
{checking ? ( {checking ? (
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span> <span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
) : nodeStatus?.installed ? ( ) : nodeStatus?.installed ? (
<span className="font-medium text-green-600 dark:text-green-400"> <span className="font-medium text-success">
{t("onboarding.nodeInstalled").replace("{version}", nodeStatus.version ?? "")} {t("onboarding.nodeInstalled").replace("{version}", nodeStatus.version ?? "")}
</span> </span>
) : nodeStatus ? ( ) : nodeStatus ? (
@@ -63,7 +63,7 @@ export function OnboardingHero({
initial={{ opacity: 0, y: 24 }} initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }}
className="relative z-10 mx-auto w-full max-w-[460px]" className="relative z-10 mx-auto w-full max-w-[min(460px,90vw)]"
> >
{/* Signature line */} {/* Signature line */}
<motion.div <motion.div
@@ -120,7 +120,7 @@ export function OnboardingHero({
<div <div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${ className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${
index < stepIndex index < stepIndex
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" ? "bg-success-muted text-success"
: index === stepIndex : index === stepIndex
? "bg-accent text-primary" ? "bg-accent text-primary"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
@@ -61,8 +61,8 @@ type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) { function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string) => string }) {
if (status === "ready") { if (status === "ready") {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/40 dark:text-green-400"> <span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" /> <span className="h-1.5 w-1.5 rounded-full bg-success" />
{t("onboarding.providerReady")} {t("onboarding.providerReady")}
</span> </span>
); );
@@ -77,8 +77,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
} }
if (status === "auth_required") { if (status === "auth_required") {
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-yellow-100 px-2 py-0.5 text-[11px] font-medium text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400"> <span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-yellow-600 dark:bg-yellow-400" /> <span className="h-1.5 w-1.5 rounded-full bg-warning" />
{t("onboarding.providerAuthRequired")} {t("onboarding.providerAuthRequired")}
</span> </span>
); );
@@ -92,8 +92,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
); );
} }
return ( return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-medium text-orange-700 dark:bg-orange-900/40 dark:text-orange-400"> <span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" /> <span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")} {status.replaceAll("_", " ")}
</span> </span>
); );
@@ -611,7 +611,7 @@ export function ProvidersStep({
{/* Error message */} {/* Error message */}
{error ? ( {error ? (
<div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-red-100 px-4 py-3 text-[13px] text-destructive dark:bg-red-900/50"> <div className="mt-5 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{error} {error}
</div> </div>
) : null} ) : null}
+1 -1
View File
@@ -6,7 +6,7 @@ import { useReducedMotion } from "./ui"
const TONE_CONFIG = { const TONE_CONFIG = {
success: { icon: CheckCircle, bg: "var(--muted-foreground)", border: "var(--primary)", text: "var(--primary)" }, success: { icon: CheckCircle, bg: "var(--muted-foreground)", border: "var(--primary)", text: "var(--primary)" },
danger: { icon: XCircle, bg: "var(--destructive)", border: "var(--destructive)", text: "var(--destructive)" }, danger: { icon: XCircle, bg: "var(--destructive)", border: "var(--destructive)", text: "var(--destructive)" },
warn: { icon: Warning, bg: "var(--muted-foreground)", border: "var(--destructive)", text: "var(--destructive)" }, warn: { icon: Warning, bg: "var(--muted-foreground)", border: "var(--warning)", text: "var(--warning)" },
} as const } as const
export default function ToastContainer() { export default function ToastContainer() {
@@ -15,8 +15,8 @@ function DeleteConfirmDialog({ open, entityName, onConfirm, onCancel }: { open:
if (!open) return null if (!open) return null
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.15 }} className="w-full max-w-[380px] rounded-lg border bg-card p-6 shadow-xl" style={{ borderColor: LINE }} onClick={(e) => e.stopPropagation()}> <motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.15 }} className="w-full max-w-[min(380px,90vw)] rounded-lg border bg-card p-6 shadow-xl" style={{ borderColor: LINE }} onClick={(e) => e.stopPropagation()}>
<div className="mb-1 flex items-center gap-2"><div className="flex h-8 w-8 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"><Trash size={16} className="text-destructive" /></div><h3 className="text-[15px] font-bold text-foreground">Delete</h3></div> <div className="mb-1 flex items-center gap-2"><div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive-muted"><Trash size={16} className="text-destructive" /></div><h3 className="text-[15px] font-bold text-foreground">Delete</h3></div>
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">Are you sure you want to delete <strong className="text-foreground">{entityName}</strong>? This action cannot be undone.</p> <p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">Are you sure you want to delete <strong className="text-foreground">{entityName}</strong>? This action cannot be undone.</p>
<div className="mt-5 flex justify-end gap-3"><button type="button" onClick={onCancel} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted">Cancel</button><ActionButton label="Delete" onClick={onConfirm} icon={Trash} tone="danger" /></div> <div className="mt-5 flex justify-end gap-3"><button type="button" onClick={onCancel} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted">Cancel</button><ActionButton label="Delete" onClick={onConfirm} icon={Trash} tone="danger" /></div>
</motion.div> </motion.div>
@@ -41,15 +41,15 @@ function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleReco
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules]) useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus]) const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
const counts = useMemo(() => ({ total: rules.length, active: rules.filter((r) => r.status === "active").length, paused: rules.filter((r) => r.status === "paused").length, error: rules.filter((r) => r.status === "error").length }), [rules]) const counts = useMemo(() => { let active = 0, paused = 0, error = 0; for (const r of rules) { if (r.status === "active") active++; else if (r.status === "paused") paused++; else if (r.status === "error") error++; } return { total: rules.length, active, paused, error } }, [rules])
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh]) const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh]) const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null} {actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}</p></div></div><ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div> <div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Automation Rules</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}</p></div></div><ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{rules.length > 0 ? <div className="grid grid-cols-3 gap-2">{[{ l: "Total Rules", v: counts.total, c: undefined }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), c: ACCENT }, { l: "Recent Activity", v: actLog.length, c: "var(--primary)" }].map((s) => <div key={s.l} className="rounded-lg border bg-card px-4 py-3.5" style={{ borderColor: LINE }}><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div><div className="text-[26px] font-bold tabular-nums" style={s.c ? { color: s.c } : undefined}>{s.v}</div></div>)}</div> : null} {rules.length > 0 ? <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">{[{ l: "Total Rules", v: counts.total, c: undefined }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), c: ACCENT }, { l: "Recent Activity", v: actLog.length, c: "var(--primary)" }].map((s) => <div key={s.l} className="rounded-lg border bg-card px-4 py-3.5" style={{ borderColor: LINE }}><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{s.l}</div><div className="text-[26px] font-bold tabular-nums" style={s.c ? { color: s.c } : undefined}>{s.v}</div></div>)}</div> : null}
<div className="flex gap-1 rounded-lg bg-muted p-1"><button type="button" onClick={() => setActiveTab("rules")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Rules ({rules.length})</button><button type="button" onClick={() => setActiveTab("activity")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Activity Log ({actLog.length})</button></div> <div className="flex gap-1 rounded-lg bg-muted p-1"><button type="button" onClick={() => setActiveTab("rules")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Rules ({rules.length})</button><button type="button" onClick={() => setActiveTab("activity")} className={`flex-1 rounded-lg px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}>Activity Log ({actLog.length})</button></div>
{activeTab === "rules" ? <> {activeTab === "rules" ? <>
{rules.length > 0 ? <div className="flex flex-wrap items-center gap-1.5">{(["all","active","paused","error"] as const).map((s) => <button key={s} type="button" onClick={() => setFilterStatus(s)} className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`} style={filterStatus === s ? { backgroundColor: ACCENT } : undefined}>{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}</button>)}</div> : null} {rules.length > 0 ? <div className="flex flex-wrap items-center gap-1.5">{(["all","active","paused","error"] as const).map((s) => <button key={s} type="button" onClick={() => setFilterStatus(s)} className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-[color:var(--border)]"}`} style={filterStatus === s ? { backgroundColor: ACCENT } : undefined}>{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}</button>)}</div> : null}
@@ -71,7 +71,7 @@ function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: Workfl
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows]) const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{actionError ? <div className="flex items-center justify-between rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null} {actionError ? <div className="flex items-center justify-between rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-destructive hover:text-primary-foreground"><X size={12} /></button></div> : null}
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><ArrowsClockwise size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.draft} draft{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}</p></div></div><ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div> <div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><ArrowsClockwise size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-foreground">Workflow Pipelines</h2><p className="text-[12px] text-muted-foreground">{counts.active} active, {counts.draft} draft{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}</p></div></div><ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
{workflows.length === 0 ? <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center" style={{ borderColor: LINE }}><ArrowsClockwise size={36} className="mb-3 text-muted-foreground" /><div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">Workflows chain actions into multi-step pipelines.</div><div className="mt-4"><ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /></div></div> {workflows.length === 0 ? <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center" style={{ borderColor: LINE }}><ArrowsClockwise size={36} className="mb-3 text-muted-foreground" /><div className="text-[14px] font-medium text-muted-foreground">No workflow pipelines</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-muted-foreground">Workflows chain actions into multi-step pipelines.</div><div className="mt-4"><ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /></div></div>
: <div className="space-y-2"><AnimatePresence>{workflows.map((wf) => <WorkflowCard key={wf.id} workflow={wf} isRunning={runningId === wf.id} onDelete={() => setDelTarget(wf)} onEdit={() => setEditWf(wf)} onRun={() => void handleRun(wf)} />)}</AnimatePresence></div>} : <div className="space-y-2"><AnimatePresence>{workflows.map((wf) => <WorkflowCard key={wf.id} workflow={wf} isRunning={runningId === wf.id} onDelete={() => setDelTarget(wf)} onEdit={() => setEditWf(wf)} onRun={() => void handleRun(wf)} />)}</AnimatePresence></div>}
@@ -27,7 +27,7 @@ export function RuleCard({ rule, onToggle, onDelete, onEdit }: { rule: Automatio
</button> </button>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<button type="button" onClick={onToggle} aria-label={rule.status === "active" ? "Pause rule" : "Activate rule"} title={rule.status === "active" ? "Pause rule" : "Activate rule"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground">{rule.status === "active" ? <Pause size={15} /> : <Play size={15} />}</button> <button type="button" onClick={onToggle} aria-label={rule.status === "active" ? "Pause rule" : "Activate rule"} title={rule.status === "active" ? "Pause rule" : "Activate rule"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground">{rule.status === "active" ? <Pause size={15} /> : <Play size={15} />}</button>
<button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button> <button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div> </div>
</div> </div>
</motion.div> </motion.div>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { Gear, Lightning, X } from "@phosphor-icons/react" import { Gear, Lightning, X } from "@phosphor-icons/react"
import type { AutomationAction, AutomationRuleRecord, AutomationTrigger, DepartmentId } from "@shared/types" import type { AutomationAction, AutomationRuleRecord, AutomationTrigger, DepartmentId } from "@shared/types"
import { ActionButton, Select } from "../ui" import { ActionButton, Select, useFocusTrap } from "../ui"
import { ACCENT, LINE, triggerConfig, actionConfig, departmentOptions, safeJsonStringify } from "./automation-config" import { ACCENT, LINE, triggerConfig, actionConfig, departmentOptions, safeJsonStringify } from "./automation-config"
interface RuleFormState { name: string; description: string; trigger: AutomationTrigger; conditionsJson: string; action: AutomationAction; actionConfigJson: string; sourceDepartment: string; targetDepartment: string; priority: number } interface RuleFormState { name: string; description: string; trigger: AutomationTrigger; conditionsJson: string; action: AutomationAction; actionConfigJson: string; sourceDepartment: string; targetDepartment: string; priority: number }
@@ -20,18 +20,20 @@ export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }
setSaving(true); setError(null) setSaving(true); setError(null)
try { const result = await window.agentCompany.saveAutomationRule({ id: editingRule?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), trigger: form.trigger, conditionsJson: form.conditionsJson, action: form.action, actionConfigJson: form.actionConfigJson, sourceDepartment: (form.sourceDepartment || null) as DepartmentId | null, targetDepartment: (form.targetDepartment || null) as DepartmentId | null, priority: form.priority, status: editingRule?.status ?? "active" }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save rule") } finally { setSaving(false) } try { const result = await window.agentCompany.saveAutomationRule({ id: editingRule?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), trigger: form.trigger, conditionsJson: form.conditionsJson, action: form.action, actionConfigJson: form.actionConfigJson, sourceDepartment: (form.sourceDepartment || null) as DepartmentId | null, targetDepartment: (form.targetDepartment || null) as DepartmentId | null, priority: form.priority, status: editingRule?.status ?? "active" }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save rule") } finally { setSaving(false) }
}, [form, editingRule, companyId, onSaved, onClose]) }, [form, editingRule, companyId, onSaved, onClose])
const trapRef = useFocusTrap(open)
useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [open, onClose])
if (!open) return null if (!open) return null
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })); const actionOptions = Object.entries(actionConfig).map(([key, c]) => ({ value: key, label: c.label })) const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })); const actionOptions = Object.entries(actionConfig).map(([key, c]) => ({ value: key, label: c.label }))
const selectedTriggerInfo = triggerConfig[form.trigger]; const selectedActionInfo = actionConfig[form.action] const selectedTriggerInfo = triggerConfig[form.trigger]; const selectedActionInfo = actionConfig[form.action]
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[580px] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}> <motion.div ref={trapRef} role="dialog" aria-modal="true" aria-label={isEditing ? "Edit Automation Rule" : "New Automation Rule"} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[min(580px,90vw)] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
<div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Automation Rule" : "New Automation Rule"}</h2></div> <div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Automation Rule" : "New Automation Rule"}</h2></div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button> <button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button>
</div> </div>
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5"> <div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
{error ? <div className="rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null} {error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Rule Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Auto-assign blocked tasks" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Rule Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Auto-assign blocked tasks" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="What does this rule do?" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="What does this rule do?" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.trigger} onChange={(v) => updateField("trigger", v as AutomationTrigger)} options={triggerOptions} />{selectedTriggerInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedTriggerInfo.description}</div> : null}</div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.trigger} onChange={(v) => updateField("trigger", v as AutomationTrigger)} options={triggerOptions} />{selectedTriggerInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedTriggerInfo.description}</div> : null}</div>
@@ -39,7 +41,7 @@ export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action</label><Select value={form.action} onChange={(v) => updateField("action", v as AutomationAction)} options={actionOptions} />{selectedActionInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedActionInfo.description}</div> : null}</div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action</label><Select value={form.action} onChange={(v) => updateField("action", v as AutomationAction)} options={actionOptions} />{selectedActionInfo ? <div className="mt-1.5 text-[11px] text-muted-foreground">{selectedActionInfo.description}</div> : null}</div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action Configuration (JSON)</label><textarea value={form.actionConfigJson} onChange={(e) => updateField("actionConfigJson", e.target.value)} rows={3} spellCheck={false} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 font-mono text-[12px] text-foreground" style={{ borderColor: LINE }} placeholder='{"targetAgentId": "..."}' /></div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Action Configuration (JSON)</label><textarea value={form.actionConfigJson} onChange={(e) => updateField("actionConfigJson", e.target.value)} rows={3} spellCheck={false} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 font-mono text-[12px] text-foreground" style={{ borderColor: LINE }} placeholder='{"targetAgentId": "..."}' /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Source Department</label><Select value={form.sourceDepartment} onChange={(v) => updateField("sourceDepartment", v)} options={departmentOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Target Department</label><Select value={form.targetDepartment} onChange={(v) => updateField("targetDepartment", v)} options={departmentOptions} /></div></div> <div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Source Department</label><Select value={form.sourceDepartment} onChange={(v) => updateField("sourceDepartment", v)} options={departmentOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Target Department</label><Select value={form.targetDepartment} onChange={(v) => updateField("targetDepartment", v)} options={departmentOptions} /></div></div>
<div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-muted-foreground">Priority</label><span className="text-[12px] font-semibold" style={{ color: ACCENT }}>{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[#d97745]" /><div className="mt-1 flex justify-between text-[10px] text-muted-foreground"><span>Low priority</span><span>High priority</span></div></div> <div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-muted-foreground">Priority</label><span className="text-[12px] font-semibold" style={{ color: ACCENT }}>{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[color:var(--primary)]" /><div className="mt-1 flex justify-between text-[10px] text-muted-foreground"><span>Low priority</span><span>High priority</span></div></div>
</div></div> </div></div>
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}> <div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
<button type="button" onClick={onClose} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">Cancel</button> <button type="button" onClick={onClose} className="rounded-lg px-4 py-2 text-[12px] font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground">Cancel</button>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { ArrowsClockwise, Gear, X } from "@phosphor-icons/react" import { ArrowsClockwise, Gear, X } from "@phosphor-icons/react"
import type { AutomationTrigger, WorkflowPipelineRecord, WorkflowStatus } from "@shared/types" import type { AutomationTrigger, WorkflowPipelineRecord, WorkflowStatus } from "@shared/types"
import { ActionButton, Select } from "../ui" import { ActionButton, Select, useFocusTrap } from "../ui"
import { ACCENT, LINE, triggerConfig, safeJsonStringify } from "./automation-config" import { ACCENT, LINE, triggerConfig, safeJsonStringify } from "./automation-config"
interface WorkflowFormState { name: string; description: string; triggerType: AutomationTrigger; triggerConfigJson: string; status: WorkflowStatus; stepsJson: string } interface WorkflowFormState { name: string; description: string; triggerType: AutomationTrigger; triggerConfigJson: string; status: WorkflowStatus; stepsJson: string }
@@ -20,15 +20,17 @@ export function WorkflowFormModal({ open, editingWorkflow, companyId, onClose, o
setSaving(true); setError(null) setSaving(true); setError(null)
try { const result = await window.agentCompany.saveWorkflow({ id: editingWorkflow?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), triggerType: form.triggerType, triggerConfigJson: form.triggerConfigJson, status: form.status, stepsJson: form.stepsJson }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save workflow") } finally { setSaving(false) } try { const result = await window.agentCompany.saveWorkflow({ id: editingWorkflow?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), triggerType: form.triggerType, triggerConfigJson: form.triggerConfigJson, status: form.status, stepsJson: form.stepsJson }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save workflow") } finally { setSaving(false) }
}, [form, editingWorkflow, companyId, onSaved, onClose]) }, [form, editingWorkflow, companyId, onSaved, onClose])
const trapRef = useFocusTrap(open)
useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose() }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey) }, [open, onClose])
if (!open) return null if (!open) return null
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })) const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label }))
const statusOptions = [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }] const statusOptions = [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }]
return ( return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[540px] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}> <motion.div ref={trapRef} role="dialog" aria-modal="true" aria-label={isEditing ? "Edit Workflow" : "New Workflow"} initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[min(540px,90vw)] flex-col rounded-lg border bg-card shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}><div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><ArrowsClockwise size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Workflow" : "New Workflow"}</h2></div><button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button></div> <div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}><div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-lg" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><ArrowsClockwise size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-foreground">{isEditing ? "Edit Workflow" : "New Workflow"}</h2></div><button type="button" onClick={onClose} className="rounded-lg p-1.5 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label="Close"><X size={16} /></button></div>
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5"> <div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
{error ? <div className="rounded-lg bg-red-100 dark:bg-red-900/50 px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null} {error ? <div className="rounded-lg bg-destructive-muted px-3.5 py-2.5 text-[12px] font-medium text-destructive">{error}</div> : null}
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Workflow Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Bug Fix Pipeline" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Workflow Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Bug Fix Pipeline" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="Describe this workflow pipeline" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div> <div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="Describe this workflow pipeline" rows={2} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border bg-card px-3.5 py-2.5 text-[14px] text-foreground placeholder:text-muted-foreground" style={{ borderColor: LINE }} /></div>
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.triggerType} onChange={(v) => updateField("triggerType", v as AutomationTrigger)} options={triggerOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Status</label><Select value={form.status} onChange={(v) => updateField("status", v as WorkflowStatus)} options={statusOptions} /></div></div> <div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Trigger</label><Select value={form.triggerType} onChange={(v) => updateField("triggerType", v as AutomationTrigger)} options={triggerOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">Status</label><Select value={form.status} onChange={(v) => updateField("status", v as WorkflowStatus)} options={statusOptions} /></div></div>
@@ -19,7 +19,7 @@ function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
<div className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" style={{ backgroundColor: colors.bg, borderColor: colors.border }} /> <div className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" style={{ backgroundColor: colors.bg, borderColor: colors.border }} />
<div className="flex-1 rounded-lg border px-3 py-2.5" style={{ borderColor: step.status === "running" ? ACCENT : LINE }}> <div className="flex-1 rounded-lg border px-3 py-2.5" style={{ borderColor: step.status === "running" ? ACCENT : LINE }}>
<div className="flex items-center justify-between"><div className="flex items-center gap-2"><span className="text-[11px] font-bold" style={{ color: colors.text }}>{idx + 1}</span><span className="text-[12px] font-medium text-foreground">{step.name}</span></div><span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}>{step.status}</span></div> <div className="flex items-center justify-between"><div className="flex items-center gap-2"><span className="text-[11px] font-bold" style={{ color: colors.text }}>{idx + 1}</span><span className="text-[12px] font-medium text-foreground">{step.name}</span></div><span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}>{step.status}</span></div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="text-yellow-600 dark:text-yellow-400">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div> <div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span className="text-warning">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted-foreground)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div>
</div> </div>
</div> </div>
) )
@@ -44,9 +44,9 @@ function WorkflowCard({ workflow, isRunning, onDelete, onEdit, onRun }: { workfl
</button> </button>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}><button type="button" onClick={() => setExpanded((p) => !p)} aria-label={expanded ? "Collapse" : "Expand"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><CaretRight size={14} /></button></motion.div> <motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}><button type="button" onClick={() => setExpanded((p) => !p)} aria-label={expanded ? "Collapse" : "Expand"} className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><CaretRight size={14} /></button></motion.div>
<button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-lg p-2 text-muted-foreground transition hover:bg-green-100 dark:bg-green-900/50 hover:text-green-600 dark:text-green-400 disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button> <button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-lg p-2 text-muted-foreground transition hover:bg-success-muted hover:text-success disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button>
<button type="button" onClick={onEdit} aria-label="Edit workflow" className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><Gear size={15} /></button> <button type="button" onClick={onEdit} aria-label="Edit workflow" className="rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"><Gear size={15} /></button>
<button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button> <button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-lg p-2 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100"><Trash size={15} /></button>
</div> </div>
</div> </div>
<AnimatePresence> <AnimatePresence>
@@ -9,7 +9,7 @@ import { useT } from "../../i18n"
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) { function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
return ( return (
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}> <button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
{sending ? <svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" /><path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg> : <PaperPlaneTilt size={18} weight="fill" />} {sending ? <svg className="h-4 w-4 animate-spin" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="2" opacity="0.3" /><path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg> : <PaperPlaneTilt size={18} weight="fill" />}
</button> </button>
) )
@@ -66,10 +66,10 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
<select value={fromAgentId} onChange={(e) => setFromAgentId(e.target.value)} className="min-w-0 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium text-foreground"> <select value={fromAgentId} onChange={(e) => setFromAgentId(e.target.value)} className="min-w-0 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium text-foreground">
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)} {agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)}
</select> </select>
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null} {!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
</div> </div>
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} /> <textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" /> <SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
</div> </div>
<div className="mt-1 text-[11px] text-muted-foreground">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div> <div className="mt-1 text-[11px] text-muted-foreground">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
@@ -87,7 +87,7 @@ export function TaskDiscussionComposer({ companyId, taskId, onSent }: { companyI
<div className="border-t border-border bg-card px-4 py-3"> <div className="border-t border-border bg-card px-4 py-3">
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null} {sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Reply in task discussion..." rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} /> <textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Reply in task discussion..." aria-label="Task discussion reply" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
<SendButton sending={sending} disabled={!body.trim()} onClick={() => void handleSend()} label="Send task comment" /> <SendButton sending={sending} disabled={!body.trim()} onClick={() => void handleSend()} label="Send task comment" />
</div> </div>
</div> </div>
@@ -14,7 +14,7 @@ import { DEPARTMENT_COLORS } from "@shared/constants"
const priorityConfig: Record<string, { label: string; className: string }> = { const priorityConfig: Record<string, { label: string; className: string }> = {
urgent: { label: "Urgent", className: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" }, urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" },
normal: { label: "Normal", className: "bg-muted text-muted-foreground" }, normal: { label: "Normal", className: "bg-muted text-muted-foreground" },
low: { label: "Low", className: "bg-muted text-muted-foreground" }, low: { label: "Low", className: "bg-muted text-muted-foreground" },
} }
@@ -23,7 +23,7 @@ function highlightMatch(text: string, query: string): React.ReactNode {
const parts = text.split(regex) const parts = text.split(regex)
return parts.map((part, i) => return parts.map((part, i) =>
regex.test(part) regex.test(part)
? <mark key={i} className="rounded-[2px] bg-yellow-100 dark:bg-yellow-900/50 px-0.5 text-foreground">{part}</mark> ? <mark key={i} className="rounded-[2px] bg-warning-muted px-0.5 text-foreground">{part}</mark>
: part : part
) )
} }
+1 -1
View File
@@ -136,7 +136,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
<div className="grid gap-4"> <div className="grid gap-4">
{submitError ? <InlineNotice message={submitError} /> : null} {submitError ? <InlineNotice message={submitError} /> : null}
{needsLogin ? ( {needsLogin ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-4 text-[12px] text-yellow-600 dark:text-yellow-400"> <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-4 text-[12px] text-warning">
<div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div> <div className="text-[11px] font-semibold uppercase tracking-[0.08em]">{authActionCopy.title}</div>
<div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div> <div className="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
<div className="mt-3"> <div className="mt-3">
@@ -37,11 +37,13 @@ export function ActivityFeed({ activity, agents, limit = 15 }: { activity: Activ
); );
} }
const agentMap = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
{recentActivity.map((item, index) => { {recentActivity.map((item, index) => {
const Icon = actionIcons[item.action] ?? Lightning; const Icon = actionIcons[item.action] ?? Lightning;
const agent = agents.find((a) => a.id === item.actor); const agent = agentMap.get(item.actor);
const actorName = agent?.name ?? (item.actor === "board" ? "Board" : item.actor.slice(0, 8)); const actorName = agent?.name ?? (item.actor === "board" ? "Board" : item.actor.slice(0, 8));
return ( return (
@@ -79,8 +79,8 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
{incidents.map((incident) => { {incidents.map((incident) => {
const isHardStop = incident.thresholdType === "hard_stop"; const isHardStop = incident.thresholdType === "hard_stop";
const borderColor = isHardStop ? "border-[color:var(--destructive)]" : "border-[color:var(--destructive)]"; const borderColor = isHardStop ? "border-[color:var(--destructive)]" : "border-[color:var(--destructive)]";
const bgColor = isHardStop ? "bg-red-100 dark:bg-red-900/50" : "bg-yellow-100 dark:bg-yellow-900/50"; const bgColor = isHardStop ? "bg-destructive-muted" : "bg-warning-muted";
const textColor = isHardStop ? "text-destructive" : "text-yellow-600 dark:text-yellow-400"; const textColor = isHardStop ? "text-destructive" : "text-warning";
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0; const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
return ( return (
@@ -132,14 +132,14 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
<div <div
role="alert" role="alert"
aria-live="assertive" aria-live="assertive"
className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3" className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0"> <div className="mt-0.5 shrink-0">
<Warning size={18} weight="bold" className="text-yellow-600 dark:text-yellow-400" /> <Warning size={18} weight="bold" className="text-warning" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-yellow-600 dark:text-yellow-400"> <div className="text-[13px] font-semibold text-warning">
Budget Alert Budget Alert
</div> </div>
<div className="mt-0.5 text-[13px] text-muted-foreground"> <div className="mt-0.5 text-[13px] text-muted-foreground">
@@ -152,7 +152,7 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
className={`whitespace-nowrap text-[11px] font-[family-name:var(--font-mono)] ${ className={`whitespace-nowrap text-[11px] font-[family-name:var(--font-mono)] ${
agent.pct >= 100 agent.pct >= 100
? "text-destructive" ? "text-destructive"
: "text-yellow-600 dark:text-yellow-400" : "text-warning"
}`} }`}
style={{ fontVariantNumeric: "tabular-nums" }} style={{ fontVariantNumeric: "tabular-nums" }}
> >
+24 -18
View File
@@ -84,7 +84,7 @@ export function Dashboard(props: DashboardProps) {
<h2 className="text-[28px] font-bold tracking-[-0.02em] text-foreground">{companyName}</h2> <h2 className="text-[28px] font-bold tracking-[-0.02em] text-foreground">{companyName}</h2>
<div className="mt-2 flex items-center gap-3 text-[13px] tracking-[0.005em] text-muted-foreground"> <div className="mt-2 flex items-center gap-3 text-[13px] tracking-[0.005em] text-muted-foreground">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" /> <span className="h-1.5 w-1.5 rounded-full bg-success" />
{activeAgents.length} {activeAgents.length === 1 ? "agent" : "agents"} active {activeAgents.length} {activeAgents.length === 1 ? "agent" : "agents"} active
</span> </span>
<span className="h-3 w-px bg-[color:var(--border)]" /> <span className="h-3 w-px bg-[color:var(--border)]" />
@@ -103,7 +103,7 @@ export function Dashboard(props: DashboardProps) {
<BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} /> <BudgetAlertBanner agents={agents} onNavigate={onNavigate} companyId={companyId} />
{/* Key Metrics — 4 cards */} {/* Key Metrics — 4 cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4">
<MetricCard <MetricCard
label={t("dash.activeTasks")} label={t("dash.activeTasks")}
value={activeTasks.length} value={activeTasks.length}
@@ -142,8 +142,8 @@ export function Dashboard(props: DashboardProps) {
{/* Needs Attention — moved to top, only shows when items exist */} {/* Needs Attention — moved to top, only shows when items exist */}
{hasAttentionItems ? ( {hasAttentionItems ? (
<section> <section>
<div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400"> <div className="mb-3 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-600 dark:bg-yellow-400" /> <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
{t("dash.needsAttentionSection")} {t("dash.needsAttentionSection")}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -157,9 +157,9 @@ export function Dashboard(props: DashboardProps) {
transition={{ duration: 0.22, delay: i * 0.04, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.22, delay: i * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button" type="button"
onClick={() => onNavigate("approvals", approval.id)} onClick={() => onNavigate("approvals", approval.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-warning-muted px-4 py-3 text-left transition hover:border-opacity-40"
> >
<ShieldCheck size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" /> <ShieldCheck size={18} className="shrink-0 text-warning" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{approval.payloadSummary}</div> <div className="truncate text-[13px] font-medium text-foreground">{approval.payloadSummary}</div>
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground"> <div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
@@ -171,14 +171,17 @@ export function Dashboard(props: DashboardProps) {
</motion.button> </motion.button>
); );
})} })}
{failedRuns.slice(0, 3).map((run) => { {failedRuns.slice(0, 3).map((run, i) => {
const agent = agentMap.get(run.agentId); const agent = agentMap.get(run.agentId);
return ( return (
<button <motion.button
key={run.id} key={run.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay: (pendingApprovals.length + i) * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button" type="button"
onClick={() => onNavigate("runs", run.id)} onClick={() => onNavigate("runs", run.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-red-100 dark:bg-red-900/50 px-4 py-3 text-left transition hover:border-opacity-40" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-destructive-muted px-4 py-3 text-left transition hover:border-opacity-40"
> >
<XCircle size={18} className="shrink-0 text-destructive" /> <XCircle size={18} className="shrink-0 text-destructive" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -188,19 +191,22 @@ export function Dashboard(props: DashboardProps) {
</div> </div>
</div> </div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" /> <ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button> </motion.button>
); );
})} })}
{urgentMessages.slice(0, 3).map((msg) => { {urgentMessages.slice(0, 3).map((msg, i) => {
const sender = agentMap.get(msg.fromAgentId); const sender = agentMap.get(msg.fromAgentId);
return ( return (
<button <motion.button
key={msg.id} key={msg.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.22, delay: (pendingApprovals.length + failedRuns.length + i) * 0.04, ease: [0.16, 1, 0.3, 1] }}
type="button" type="button"
onClick={() => onNavigate("communication", msg.id)} onClick={() => onNavigate("communication", msg.id)}
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3 text-left transition hover:border-opacity-40" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border border-[color:var(--destructive)] border-opacity-20 bg-warning-muted px-4 py-3 text-left transition hover:border-opacity-40"
> >
<Warning size={18} className="shrink-0 text-yellow-600 dark:text-yellow-400" /> <Warning size={18} className="shrink-0 text-warning" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-foreground">{msg.subject}</div> <div className="truncate text-[13px] font-medium text-foreground">{msg.subject}</div>
<div className="mt-0.5 text-[11px] text-muted-foreground"> <div className="mt-0.5 text-[11px] text-muted-foreground">
@@ -208,7 +214,7 @@ export function Dashboard(props: DashboardProps) {
</div> </div>
</div> </div>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" /> <ArrowRight size={14} className="shrink-0 text-muted-foreground" />
</button> </motion.button>
); );
})} })}
</div> </div>
@@ -235,11 +241,11 @@ export function Dashboard(props: DashboardProps) {
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 px-4 py-3 text-left transition hover:bg-muted"
> >
<div className="relative shrink-0"> <div className="relative shrink-0">
<Robot size={16} className="text-green-600 dark:text-green-400" /> <Robot size={16} className="text-success" />
{run.status === "running" ? ( {run.status === "running" ? (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2"> <span className="absolute -right-0.5 -top-0.5 h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" /> <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-success" />
</span> </span>
) : null} ) : null}
</div> </div>
@@ -11,10 +11,10 @@ interface KanbanProps {
const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [ const KANBAN_COLUMNS: Array<{ status: TaskRecord["status"]; label: string; tone: string; dotColor: string }> = [
{ status: "backlog", label: "Backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" }, { status: "backlog", label: "Backlog", tone: "text-muted-foreground", dotColor: "bg-[color:var(--muted-foreground)]" },
{ status: "todo", label: "Todo", tone: "text-yellow-600 dark:text-yellow-400", dotColor: "bg-yellow-600 dark:bg-yellow-400" }, { status: "todo", label: "Todo", tone: "text-warning", dotColor: "bg-warning" },
{ status: "in_progress", label: "In Progress", tone: "text-primary", dotColor: "bg-primary" }, { status: "in_progress", label: "In Progress", tone: "text-primary", dotColor: "bg-primary" },
{ status: "in_review", label: "In Review", tone: "text-yellow-600 dark:text-yellow-400", dotColor: "bg-yellow-600 dark:bg-yellow-400" }, { status: "in_review", label: "In Review", tone: "text-warning", dotColor: "bg-warning" },
{ status: "done", label: "Done", tone: "text-green-600 dark:text-green-400", dotColor: "bg-green-600 dark:bg-green-400" }, { status: "done", label: "Done", tone: "text-success", dotColor: "bg-success" },
{ status: "blocked", label: "Blocked", tone: "text-destructive", dotColor: "bg-destructive" }, { status: "blocked", label: "Blocked", tone: "text-destructive", dotColor: "bg-destructive" },
]; ];
@@ -57,7 +57,7 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
{KANBAN_COLUMNS.map((col) => { {KANBAN_COLUMNS.map((col) => {
const colTasks = tasksByStatus[col.status] ?? []; const colTasks = tasksByStatus[col.status] ?? [];
return ( return (
<div key={col.status} className="flex w-[220px] min-w-[180px] shrink-0 flex-col"> <div key={col.status} className="flex w-[220px] min-w-[160px] shrink-0 flex-col lg:w-[240px]">
<div className="mb-2 flex items-center gap-2 px-1"> <div className="mb-2 flex items-center gap-2 px-1">
<span className={`h-2.5 w-2.5 rounded-full ${col.dotColor}`} aria-hidden="true" /> <span className={`h-2.5 w-2.5 rounded-full ${col.dotColor}`} aria-hidden="true" />
<span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span> <span className={`text-[11px] font-semibold uppercase tracking-[0.08em] ${col.tone}`}>{col.label}</span>
@@ -88,14 +88,14 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
<span className="text-[10px] italic text-muted-foreground">Unassigned</span> <span className="text-[10px] italic text-muted-foreground">Unassigned</span>
)} )}
{isRunning ? ( {isRunning ? (
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-green-600 dark:text-green-400"> <span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-success">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" /> <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-success" aria-hidden="true" />
Running Running
</span> </span>
) : null} ) : null}
</div> </div>
{task.priority === "critical" || task.priority === "high" ? ( {task.priority === "critical" || task.priority === "high" ? (
<div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-destructive" : "text-yellow-600 dark:text-yellow-400"}`}> <div className={`mt-1 text-[9px] font-bold uppercase tracking-[0.12em] ${task.priority === "critical" ? "text-destructive" : "text-warning"}`}>
{task.priority} {task.priority}
</div> </div>
) : null} ) : null}
@@ -17,23 +17,23 @@ export function MetricCard({
}) { }) {
const toneMap = { const toneMap = {
default: "text-foreground", default: "text-foreground",
success: "text-green-600 dark:text-green-400", success: "text-success",
warn: "text-yellow-600 dark:text-yellow-400", warn: "text-warning",
danger: "text-destructive", danger: "text-destructive",
accent: "text-primary", accent: "text-primary",
}; };
const bgMap = { const bgMap = {
default: "bg-muted", default: "bg-muted",
success: "bg-green-100 dark:bg-green-900/50", success: "bg-success-muted",
warn: "bg-yellow-100 dark:bg-yellow-900/50", warn: "bg-warning-muted",
danger: "bg-red-100 dark:bg-red-900/50", danger: "bg-destructive-muted",
accent: "bg-accent", accent: "bg-accent",
}; };
const borderMap = { const borderMap = {
default: "border-l-transparent", default: "border-l-transparent",
success: "border-l-[color:var(--primary)]", success: "border-l-[color:var(--success)]",
warn: "border-l-[color:var(--destructive)]", warn: "border-l-[color:var(--warning)]",
danger: "border-l-[color:var(--destructive)]", danger: "border-l-[color:var(--destructive)]",
accent: "border-l-[color:var(--primary)]", accent: "border-l-[color:var(--primary)]",
}; };
@@ -42,7 +42,7 @@ export function MetricCard({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center gap-4 rounded-lg border border-l-[3px] border-border bg-card px-4 py-4 text-left transition duration-150 hover:border-border hover:shadow-sm ${borderMap[tone]}`} className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex items-center gap-4 rounded-lg border border-l-[3px] border-border bg-card px-4 py-4 text-left transition duration-200 hover:shadow-md hover:-translate-y-0.5 ${borderMap[tone]}`}
> >
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg ${bgMap[tone]}`}> <div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-lg ${bgMap[tone]}`}>
<Icon size={24} weight="fill" className={toneMap[tone]} /> <Icon size={24} weight="fill" className={toneMap[tone]} />
@@ -41,9 +41,9 @@ export function RecoveryBanner() {
const handleDismissAll = () => setDismissed(true); const handleDismissAll = () => setDismissed(true);
return ( return (
<div className="bg-yellow-100 dark:bg-yellow-900/50 border border-[color:var(--destructive)]/30 rounded-lg px-4 py-4 mb-4"> <div className="bg-warning-muted border border-[color:var(--destructive)]/30 rounded-lg px-4 py-4 mb-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 font-medium"> <div className="flex items-center gap-2 text-warning font-medium">
<Warning size={18} weight="bold" /> <Warning size={18} weight="bold" />
<span>{runs.length} interrupted task(s) detected from previous session</span> <span>{runs.length} interrupted task(s) detected from previous session</span>
</div> </div>
@@ -61,7 +61,7 @@ export function RecoveryBanner() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => handleRetry(run)} onClick={() => handleRetry(run)}
className="flex items-center gap-1 text-green-600 dark:text-green-400 hover:text-green-600 dark:text-green-400 text-xs transition-colors" className="flex items-center gap-1 text-success hover:text-success text-xs transition-colors"
> >
<ArrowClockwise size={14} /> Retry <ArrowClockwise size={14} /> Retry
</button> </button>
@@ -17,8 +17,8 @@ interface BudgetRow {
function getBarColor(pct: number): string { function getBarColor(pct: number): string {
if (pct >= 100) return "bg-destructive"; if (pct >= 100) return "bg-destructive";
if (pct >= 80) return "bg-yellow-600 dark:bg-yellow-400"; if (pct >= 80) return "bg-warning";
return "bg-green-600 dark:bg-green-400"; return "bg-success";
} }
function scopeLabel(scopeType: string): string { function scopeLabel(scopeType: string): string {
@@ -119,7 +119,7 @@ export function BudgetUtilization({ companyId, agents }: BudgetUtilizationProps)
/> />
{/* 80% warning marker */} {/* 80% warning marker */}
<div <div
className="absolute top-0 h-full w-px bg-yellow-600 dark:bg-yellow-400 opacity-50" className="absolute top-0 h-full w-px bg-warning opacity-50"
style={{ left: "80%" }} style={{ left: "80%" }}
/> />
</div> </div>
@@ -44,7 +44,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative flex-1"> <div className="relative flex-1">
<MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> <MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input type="text" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} /> <input type="text" aria-label={t("exec.searchActivity")} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 w-full rounded-lg border border-border bg-card py-2.5 pl-8 pr-3.5 text-[13px] text-foreground placeholder:text-muted-foreground" value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("exec.searchActivity")} />
</div> </div>
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} /> <Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
</div> </div>
@@ -12,8 +12,8 @@ import { useAppStore } from "../../state/store"
function getTypeBadgeClass(type: string): string { function getTypeBadgeClass(type: string): string {
if (type === "hire_agent") return "bg-accent text-primary" if (type === "hire_agent") return "bg-accent text-primary"
if (type === "approve_ceo_strategy") return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400" if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
if (type === "secret_access") return "bg-red-100 dark:bg-red-900/50 text-destructive" if (type === "secret_access") return "bg-destructive-muted text-destructive"
if (type === "social_post") return "bg-accent text-primary" if (type === "social_post") return "bg-accent text-primary"
if (type === "deliverable_review") return "bg-accent text-primary" if (type === "deliverable_review") return "bg-accent text-primary"
return "bg-muted text-muted-foreground" return "bg-muted text-muted-foreground"
@@ -113,7 +113,7 @@ export function ApprovalInspector({ approval, agents, tasks, documents, companyI
<div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div> <div><span className="text-muted-foreground">Budget:</span> <span className="font-mono text-foreground">{formatMoney(proposedAgent?.budgetMonthlyUsd ?? (typeof reqSnap?.budgetMonthlyUsd === "number" ? reqSnap.budgetMonthlyUsd : 0))}/mo</span></div>
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null} {mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></div> : null}
</div></div>) })() : null} </div></div>) })() : null}
{approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-yellow-600 dark:text-yellow-400">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null} {approval.type === "approve_ceo_strategy" ? <div className="rounded-lg border border-[color:var(--destructive)] border-opacity-30 bg-warning-muted px-4 py-3"><div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-warning">Organization Strategy</div><div className="whitespace-pre-wrap text-[12px] leading-relaxed text-foreground">{approval.payloadSummary}</div></div> : null}
{approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null} {approval.type === "deliverable_review" ? <DeliverableReviewPanel approval={approval} documents={documents} agents={agents} /> : null}
{isSocialPost && approval.payloadJson ? ( {isSocialPost && approval.payloadJson ? (
<SocialDraftPreview <SocialDraftPreview
@@ -53,11 +53,11 @@ export function ApprovalList({ approvals, selectedApprovalId, onSelect }: { appr
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`} className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ${virtualRow.index === focusedIndex ? "ring-1 ring-[color:var(--primary)] ring-inset" : ""}`}
> >
<button <button
className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-yellow-100 dark:bg-yellow-900/50" : "bg-transparent hover:bg-muted"}`} className={`relative w-full border-b border-border px-4 py-3 text-left transition duration-150 last:border-b-0 ${selectedApprovalId === approval.id ? "bg-warning-muted" : "bg-transparent hover:bg-muted"}`}
onClick={() => onSelect(approval.id)} onClick={() => onSelect(approval.id)}
tabIndex={-1} tabIndex={-1}
> >
{selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-yellow-600 dark:bg-yellow-400" /> : null} {selectedApprovalId === approval.id ? <span className="absolute inset-y-0 left-0 w-[3px] bg-warning" /> : null}
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div> <div className={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
<StatusPill status={approval.state} /> <StatusPill status={approval.state} />
@@ -45,8 +45,8 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0 const pct = status.amount > 0 ? (status.spent / status.amount) * 100 : 0
const isWarning = pct > 80 && pct <= 100 const isWarning = pct > 80 && pct <= 100
const isDanger = pct > 100 const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400" const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground" const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
const scopeLabel = status.scopeType === "company" const scopeLabel = status.scopeType === "company"
? "Company" ? "Company"
@@ -62,11 +62,11 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{status.scopeType} {status.scopeType}
</span> </span>
<div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div> <div className={`text-[14px] font-medium ${nameColor}`}>{scopeLabel}</div>
{status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null} {status.hasOpenIncident ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div> <div className="font-mono text-[12px] text-foreground">{formatMoney(status.spent / 100)} / {formatMoney(status.amount / 100)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span> <span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div> </div>
</div> </div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div> <div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -79,19 +79,19 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
{agents.map((agent) => { {agents.map((agent) => {
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0 const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100 const isWarning = pct > 80 && pct <= 100; const isDanger = pct > 100
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400" const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground" const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
return ( return (
<ListRow key={agent.id}> <ListRow key={agent.id}>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div> <div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
{isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null} {isDanger ? <Warning size={14} weight="fill" className="text-destructive" /> : null}
{isWarning ? <Warning size={14} weight="fill" className="text-yellow-600 dark:text-yellow-400" /> : null} {isWarning ? <Warning size={14} weight="fill" className="text-warning" /> : null}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div> <div className="font-mono text-[12px] text-foreground">{formatMoney(agent.spentMonthlyUsd)} / {formatMoney(agent.budgetMonthlyUsd)}</div>
<span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>{Math.round(pct)}%</span> <span className={`font-mono text-[11px] font-semibold ${isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-muted-foreground"}`}>{Math.round(pct)}%</span>
</div> </div>
</div> </div>
<div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div> <div className="h-2 rounded-full bg-[color:var(--border)]"><div className={`h-2 rounded-full ${barColor}`} style={{ width: `${Math.min(100, pct)}%` }} /></div>
@@ -100,7 +100,7 @@ export function CostList({ costs, agents, runs }: { costs: ProfileSnapshot["cost
</div> </div>
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<div className="font-mono tabular-nums text-[13px] text-foreground">{formatMoney(entry.amountUsd)}</div> <div className="font-mono tabular-nums text-[13px] text-foreground">{formatMoney(entry.amountUsd)}</div>
{entry.unattributed ? <div className="text-[11px] text-yellow-600 dark:text-yellow-400">unattributed</div> : null} {entry.unattributed ? <div className="text-[11px] text-warning">unattributed</div> : null}
</div> </div>
</div> </div>
</div> </div>
@@ -11,8 +11,8 @@ interface IncidentListProps {
} }
const STATUS_STYLES: Record<string, string> = { const STATUS_STYLES: Record<string, string> = {
open: "text-destructive bg-red-100 dark:bg-red-900/50", open: "text-destructive bg-destructive-muted",
resolved: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50", resolved: "text-success bg-success-muted",
auto_resolved_limit_increase: "text-primary bg-accent", auto_resolved_limit_increase: "text-primary bg-accent",
auto_closed: "text-muted-foreground bg-muted", auto_closed: "text-muted-foreground bg-muted",
}; };
@@ -93,8 +93,8 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
<span <span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
isWarn isWarn
? "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50" ? "text-warning bg-warning-muted"
: "text-destructive bg-red-100 dark:bg-red-900/50" : "text-destructive bg-destructive-muted"
}`} }`}
> >
{isWarn ? <Warning size={10} weight="bold" /> : null} {isWarn ? <Warning size={10} weight="bold" /> : null}
@@ -1,9 +1,52 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { FloppyDisk, Plus, X as XIcon } from "@phosphor-icons/react"; import {
CaretDown,
CaretRight,
FloppyDisk,
Plus,
SignIn,
Trash,
ArrowSquareOut,
X as XIcon,
} from "@phosphor-icons/react";
import type { ConnectorRecord, ProfileSnapshot } from "@shared/types"; import type { ConnectorRecord, ProfileSnapshot } from "@shared/types";
import type { AppLocale } from "@shared/locale"; import type { AppLocale } from "@shared/locale";
import { unwrap } from "../../lib/desktop"; import { unwrap } from "../../lib/desktop";
import { ActionButton } from "../ui";
/* ------------------------------------------------------------------ */
/* Compatible-API provider presets (mirrors ProvidersStep) */
/* ------------------------------------------------------------------ */
const COMPATIBLE_PROVIDERS = [
{ id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "minimax_cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/anthropic", docUrl: "https://platform.minimax.io/docs/token-plan/claude-code" },
{ id: "kimi", label: "Kimi", baseUrl: "https://api.kimi.com/coding/", docUrl: "https://www.kimi.com/code/docs/en/more/third-party-agents.html" },
{ id: "kimi_cn", label: "Kimi CN", baseUrl: "https://api.moonshot.cn/anthropic", docUrl: "https://platform.moonshot.cn/docs/guide/agent-support" },
{ id: "glm", label: "GLM (ZhiPu)", baseUrl: "https://api.z.ai/api/anthropic", docUrl: "https://docs.z.ai/devpack/tool/claude" },
{ id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api", docUrl: "https://openrouter.ai/docs/guides/coding-agents/claude-code-integration" },
{ id: "tencent", label: "Tencent Coding Plan", baseUrl: "https://api.lkeap.cloud.tencent.com/coding/anthropic", docUrl: "https://cloud.tencent.com/document/product/1772/128951" },
{ id: "poe", label: "POE", baseUrl: "https://api.poe.com", docUrl: "https://creator.poe.com/docs/external-applications/claude-code" },
{ id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic", docUrl: "https://api-docs.deepseek.com/guides/anthropic_api" },
{ id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan" },
{ id: "alibaba_cn", label: "Alibaba Coding Plan (CN)", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", docUrl: "https://help.aliyun.com/zh/model-studio/claude-code-coding-plan" },
{ id: "bailing", label: "BaiLing", baseUrl: "https://api.tbox.cn/api/anthropic", docUrl: "https://alipaytbox.yuque.com/sxs0ba/ling/best_practices_claudecode" },
{ id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", docUrl: "https://docs.nano-gpt.com/integrations/claude-code#manual-configuration-all-platforms" },
{ id: "zhipu_cn", label: "ZhiPu (CN)", baseUrl: "https://open.bigmodel.cn/api/anthropic", docUrl: "https://docs.bigmodel.cn/cn/coding-plan/tool/claude" },
{ id: "ollama", label: "Ollama (Local)", baseUrl: "http://localhost:11434", docUrl: "https://docs.ollama.com/integrations/claude-code" },
{ id: "custom", label: "Custom", baseUrl: "", docUrl: "" },
] as const;
const COMPAT_SLOTS = [
"claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
] as const;
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
const INPUT_CLS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring w-full rounded-lg border border-border bg-card px-3 py-2.5 text-[13px] text-foreground placeholder:text-muted-foreground";
interface ModelManagementProps { interface ModelManagementProps {
connectors: ProfileSnapshot["connectors"]; connectors: ProfileSnapshot["connectors"];
@@ -11,194 +54,738 @@ interface ModelManagementProps {
onSaved: () => Promise<void>; onSaved: () => Promise<void>;
} }
/** Check if a claude_compat connector has been configured (has env bindings). */ interface ModelEntry {
function isConfigured(c: ConnectorRecord): boolean { id: string;
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY"); label: string;
enabled: boolean;
} }
function getDisplayName(connector: ConnectorRecord): string { type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
return connector.label; function parseEnvValue(text: string, key: string): string {
const line = text.split(/\r?\n/).find((l) => l.startsWith(`${key}=`));
return line ? line.slice(key.length + 1) : "";
} }
function CompatModelEditor({ /* ------------------------------------------------------------------ */
/* StatusBadge */
/* ------------------------------------------------------------------ */
function StatusBadge({ status, zh }: { status: ConnectorStatus; zh: boolean }) {
if (status === "ready") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success-muted px-2 py-0.5 text-[11px] font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
{zh ? "就绪" : "Ready"}
</span>
);
}
if (status === "not_installed") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground" />
{zh ? "未安装" : "Not installed"}
</span>
);
}
if (status === "auth_required") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-muted px-2 py-0.5 text-[11px] font-medium text-warning">
<span className="h-1.5 w-1.5 rounded-full bg-warning" />
{zh ? "需登录" : "Auth required"}
</span>
);
}
if (status === "detected" || (status as string) === "checking") {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground border-t-primary" />
{zh ? "检测中" : "Checking"}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-caution-muted px-2 py-0.5 text-[11px] font-medium text-caution">
<span className="h-1.5 w-1.5 rounded-full bg-caution" />
{status.replaceAll("_", " ")}
</span>
);
}
/* ------------------------------------------------------------------ */
/* ProviderCard */
/* ------------------------------------------------------------------ */
function ProviderCard({
title,
badge,
expanded,
onToggle,
children,
}: {
title: string;
badge: React.ReactNode;
expanded: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full cursor-pointer items-center justify-between px-4 py-3 transition hover:bg-muted/50"
>
<div className="flex items-center gap-2.5">
{expanded ? (
<CaretDown size={14} className="text-muted-foreground" />
) : (
<CaretRight size={14} className="text-muted-foreground" />
)}
<span className="text-[13px] font-semibold text-foreground">{title}</span>
</div>
{badge}
</button>
{expanded && <div className="space-y-3 px-4 pb-4">{children}</div>}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Toggle switch */
/* ------------------------------------------------------------------ */
function ToggleSwitch({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
return (
<button
type="button"
onClick={onChange}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${checked ? "bg-success" : "bg-muted-foreground/30"}`}
role="switch"
aria-checked={checked}
aria-label={label}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
/>
</button>
);
}
/* ------------------------------------------------------------------ */
/* Model toggle list */
/* ------------------------------------------------------------------ */
function ModelToggleList({
models,
onToggle,
onRemove,
canRemove,
zh,
}: {
models: ModelEntry[];
onToggle: (id: string) => void;
onRemove?: (id: string) => void;
canRemove: boolean;
zh: boolean;
}) {
if (models.length === 0) {
return (
<div className="text-[12px] text-muted-foreground">
{zh ? "暂无模型" : "No models available"}
</div>
);
}
const enabledCount = models.filter((m) => m.enabled).length;
return (
<div>
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">
{zh ? "模型" : "Models"} ({enabledCount}/{models.length} {zh ? "已启用" : "enabled"})
</div>
<div className="space-y-1">
{models.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
>
<div className="flex items-center gap-2.5">
<ToggleSwitch
checked={m.enabled}
onChange={() => onToggle(m.id)}
label={`${m.enabled ? "Disable" : "Enable"} ${m.label}`}
/>
<span
className={`text-[12px] ${m.enabled ? "text-foreground" : "text-muted-foreground line-through"}`}
>
{m.label}
</span>
</div>
{canRemove && onRemove ? (
<button
type="button"
onClick={() => onRemove(m.id)}
className="text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m.label}`}
>
<XIcon size={12} />
</button>
) : null}
</div>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Native provider card (Claude Code / Codex / Gemini) */
/* ------------------------------------------------------------------ */
function NativeProviderCard({
connector, connector,
locale, zh,
onSaved, onSaved,
expanded,
onToggle,
}: { }: {
connector: ConnectorRecord; connector: ConnectorRecord;
locale: AppLocale; zh: boolean;
onSaved: () => Promise<void>; onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
}) { }) {
const [models, setModels] = useState<string[]>(() => { const [models, setModels] = useState<ModelEntry[]>([]);
const raw = connector.model ?? "";
return raw.split(",").map((m) => m.trim()).filter(Boolean);
});
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const envKeyName = connector.id === "codex_local" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
const [apiKey, setApiKey] = useState(() => parseEnvValue(connector.envBindingText, envKeyName));
useEffect(() => { useEffect(() => {
const raw = connector.model ?? ""; setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean)); }, [connector.envBindingText, envKeyName]);
setDirty(false);
}, [connector.model]);
function addModel() { const loadModels = useCallback(async () => {
const trimmed = newModel.trim(); try {
if (!trimmed || models.includes(trimmed)) return; const [enabledResult, disabledResult] = await Promise.all([
setModels((prev) => [...prev, trimmed]); window.agentCompany.listConnectorModels(connector.id),
setNewModel(""); window.agentCompany.getConnectorDisabledModels(connector.id),
setDirty(true); ]);
} const enabled = enabledResult.ok ? enabledResult.data : [];
const disabled = disabledResult.ok ? disabledResult.data : [];
setModels([
...enabled.map((m) => ({ ...m, enabled: true })),
...disabled.map((m) => ({ ...m, enabled: false })),
]);
} catch {
/* non-fatal */
}
}, [connector.id]);
function removeModel(m: string) { useEffect(() => {
setModels((prev) => prev.filter((x) => x !== m)); void loadModels();
setDirty(true); }, [loadModels]);
async function handleToggleModel(modelId: string) {
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
} }
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
try { try {
unwrap(await window.agentCompany.saveConnector({ const lines: string[] = [];
id: connector.id, if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
command: connector.command, unwrap(
model: models.join(",") || null, await window.agentCompany.saveConnector({
envBindingText: connector.envBindingText, id: connector.id,
notes: connector.notes, command: connector.command,
})); model: connector.model,
setDirty(false); envBindingText: lines.join("\n"),
notes: connector.notes,
}),
);
await window.agentCompany.testConnector(connector.id);
await onSaved();
void loadModels();
} finally {
setSaving(false);
}
}
async function handleLogin() {
await window.agentCompany.openConnectorAuthTerminal(connector.id);
}
const showApiKey = connector.id === "claude_local" || connector.id === "codex_local";
return (
<ProviderCard
title={connector.label}
badge={<StatusBadge status={connector.status} zh={zh} />}
expanded={expanded}
onToggle={onToggle}
>
<button
type="button"
onClick={() => void handleLogin()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted"
>
<SignIn size={14} />
{zh ? "登录" : "Login"}
</button>
{showApiKey ? (
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{envKeyName}{" "}
<span className="font-normal">({zh ? "可选" : "optional"})</span>
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={connector.id === "codex_local" ? "sk-..." : "sk-ant-..."}
/>
</div>
) : null}
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
canRemove={false}
zh={zh}
/>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Compatible provider card */
/* ------------------------------------------------------------------ */
function CompatProviderCard({
slotId,
connector,
zh,
onSaved,
expanded,
onToggle,
onCleared,
}: {
slotId: string;
connector: ConnectorRecord | null;
zh: boolean;
onSaved: () => Promise<void>;
expanded: boolean;
onToggle: () => void;
onCleared?: () => void;
}) {
const [selectedProvider, setSelectedProvider] = useState<string>(() => {
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
return match?.id ?? COMPATIBLE_PROVIDERS[0].id;
});
const [apiKey, setApiKey] = useState(() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"),
);
const [baseUrl, setBaseUrl] = useState<string>(
() =>
parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL") ||
COMPATIBLE_PROVIDERS[0].baseUrl,
);
const [models, setModels] = useState<ModelEntry[]>([]);
const [newModel, setNewModel] = useState("");
const [saving, setSaving] = useState(false);
const status = connector?.status ?? "not_installed";
const providerLabel =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ??
"Compatible API";
const displayTitle = connector?.notes || providerLabel;
// Sync form state when connector changes externally (e.g. after save + refresh)
useEffect(() => {
setApiKey(parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_API_KEY"));
const savedBase = parseEnvValue(connector?.envBindingText ?? "", "ANTHROPIC_BASE_URL");
if (savedBase) setBaseUrl(savedBase);
const saved = connector?.notes ?? "";
const match = COMPATIBLE_PROVIDERS.find((p) => p.label === saved);
if (match) setSelectedProvider(match.id);
}, [connector?.envBindingText, connector?.notes]);
const loadModels = useCallback(async () => {
if (!connector) {
setModels([]);
return;
}
try {
const raw = connector.model ?? "";
const allModelIds = raw
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const disabledResult = await window.agentCompany.getConnectorDisabledModels(
connector.id,
);
const disabledSet = new Set(
(disabledResult.ok ? disabledResult.data : []).map((m) => m.id),
);
setModels(
allModelIds.map((id) => ({
id,
label: id,
enabled: !disabledSet.has(id),
})),
);
} catch {
/* non-fatal */
}
}, [connector?.id, connector?.model]);
useEffect(() => {
void loadModels();
}, [loadModels]);
function handleProviderChange(providerId: string) {
setSelectedProvider(providerId);
const provider = COMPATIBLE_PROVIDERS.find((p) => p.id === providerId);
if (provider) setBaseUrl(provider.baseUrl);
}
function addModel() {
const trimmed = newModel.trim();
if (!trimmed || models.some((m) => m.id === trimmed)) return;
setModels((prev) => [...prev, { id: trimmed, label: trimmed, enabled: true }]);
setNewModel("");
}
function removeModel(modelId: string) {
setModels((prev) => prev.filter((m) => m.id !== modelId));
}
async function handleToggleModel(modelId: string) {
if (!connector) return;
const prev = models;
const updated = models.map((m) =>
m.id === modelId ? { ...m, enabled: !m.enabled } : m,
);
setModels(updated);
const newDisabled = updated
.filter((m) => !m.enabled)
.map((m) => ({ id: m.id, label: m.label }));
try {
unwrap(
await window.agentCompany.setConnectorDisabledModels({
connectorId: connector.id,
disabledModels: newDisabled,
}),
);
await onSaved();
} catch {
setModels(prev);
}
}
async function handleSave() {
setSaving(true);
try {
const lines: string[] = [];
if (apiKey.trim()) lines.push(`ANTHROPIC_API_KEY=${apiKey.trim()}`);
if (baseUrl.trim()) lines.push(`ANTHROPIC_BASE_URL=${baseUrl.trim()}`);
const label =
COMPATIBLE_PROVIDERS.find((p) => p.id === selectedProvider)?.label ?? "";
const modelsCsv =
models.map((m) => m.id).join(",") || null;
unwrap(
await window.agentCompany.saveConnector({
id: slotId as ConnectorRecord["id"],
command: connector?.command ?? "claude",
model: modelsCsv,
envBindingText: lines.join("\n"),
notes: label,
}),
);
await window.agentCompany.testConnector(slotId);
await onSaved(); await onSaved();
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
return ( async function handleClear() {
<div> if (!connector) return;
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
{models.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-1.5">
{models.map((m) => (
<span
key={m}
className="inline-flex items-center gap-1 rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
>
{m}
<button
type="button"
onClick={() => removeModel(m)}
className="ml-0.5 text-muted-foreground transition hover:text-destructive"
aria-label={`Remove ${m}`}
>
<XIcon size={10} />
</button>
</span>
))}
</div>
) : (
<div className="mb-2 text-[12px] text-muted-foreground">
{locale === "zh" ? "未配置模型" : "No models configured"}
</div>
)}
<div className="flex items-center gap-2">
<input
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={locale === "zh" ? "输入模型名称" : "model-name"}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addModel(); } }}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card p-2 text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label="Add model"
>
<Plus size={14} />
</button>
{dirty ? (
<ActionButton
label={locale === "zh" ? "保存" : "Save"}
tone="accent"
icon={FloppyDisk}
onClick={() => void handleSave()}
loading={saving}
/>
) : null}
</div>
</div>
);
}
function NativeModelList({
connector,
locale,
}: {
connector: ConnectorRecord;
locale: AppLocale;
}) {
const [models, setModels] = useState<Array<{ id: string; label: string }>>([]);
const loadModels = useCallback(async () => {
try { try {
const result = await window.agentCompany.listConnectorModels(connector.id); unwrap(
if (result.ok && result.data.length > 0) { await window.agentCompany.saveConnector({
setModels(result.data); id: connector.id,
} command: connector.command,
} catch { /* non-fatal */ } model: null,
}, [connector.id]); envBindingText: "",
notes: "",
useEffect(() => { void loadModels(); }, [loadModels]); }),
);
return ( unwrap(
<div> await window.agentCompany.setConnectorDisabledModels({
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div> connectorId: connector.id,
{models.length > 0 ? ( disabledModels: [],
<div className="flex flex-wrap gap-1.5"> }),
{models.map((m) => ( );
<span await onSaved();
key={m.id} onCleared?.();
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground" } catch {
> /* non-fatal */
{m.label} }
</span>
))}
</div>
) : (
<div className="text-[12px] text-muted-foreground">
{locale === "zh" ? "使用默认模型" : "Using default model"}
</div>
)}
</div>
);
}
export function ModelManagement({ connectors, locale, onSaved }: ModelManagementProps) {
// Show native connectors that are ready + claude_compat connectors that are configured
const visibleConnectors = connectors.filter((c) => {
if (c.id.startsWith("claude_compat")) return isConfigured(c);
return c.status === "ready";
});
if (visibleConnectors.length === 0) {
return (
<div className="py-6 text-center text-[13px] text-muted-foreground">
{locale === "zh" ? "暂无就绪的提供商" : "No ready providers"}
</div>
);
} }
const selectedProviderData = COMPATIBLE_PROVIDERS.find(
(p) => p.id === selectedProvider,
);
const isConfigured =
connector &&
(connector.envBindingText.includes("ANTHROPIC_BASE_URL") ||
connector.envBindingText.includes("ANTHROPIC_API_KEY"));
return ( return (
<div className="space-y-5"> <ProviderCard
{visibleConnectors.map((connector) => title={displayTitle}
connector.id.startsWith("claude_compat") ? ( badge={<StatusBadge status={status} zh={zh} />}
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} /> expanded={expanded}
) : ( onToggle={onToggle}
<NativeModelList key={connector.id} connector={connector} locale={locale} /> >
), <div>
)} <label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "选择提供商" : "Select provider"}
</label>
<select
className={INPUT_CLS}
value={selectedProvider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{COMPATIBLE_PROVIDERS.map((p) => (
<option key={p.id} value={p.id} className="bg-card text-foreground">
{p.label}
</option>
))}
</select>
</div>
{selectedProviderData?.docUrl ? (
<button
type="button"
onClick={() => window.open(selectedProviderData.docUrl, "_blank")}
className="inline-flex items-center gap-1 text-[12px] text-primary transition hover:underline"
>
<ArrowSquareOut size={12} />
{zh ? "查看文档" : "View docs"}
</button>
) : null}
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_API_KEY
</label>
<input
type="password"
className={INPUT_CLS}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
ANTHROPIC_BASE_URL
</label>
<input
className={INPUT_CLS}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<ModelToggleList
models={models}
onToggle={(id) => void handleToggleModel(id)}
onRemove={removeModel}
canRemove
zh={zh}
/>
<div>
<label className="mb-1.5 block text-[12px] font-medium text-muted-foreground">
{zh ? "添加模型" : "Add model"}
</label>
<div className="flex gap-2">
<input
className={INPUT_CLS}
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder={zh ? "输入模型名称" : "model-name"}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addModel();
}
}}
/>
<button
type="button"
onClick={addModel}
disabled={!newModel.trim()}
className="shrink-0 rounded-lg border border-border bg-card px-3 py-2 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
aria-label={zh ? "添加模型" : "Add model"}
>
<Plus size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-foreground transition hover:bg-muted disabled:opacity-40"
>
<FloppyDisk size={14} />
{saving
? zh
? "保存中..."
: "Saving..."
: zh
? "保存并检查"
: "Save & Check"}
</button>
{isConfigured ? (
<button
type="button"
onClick={() => void handleClear()}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-[12px] font-medium text-destructive transition hover:bg-muted"
>
<Trash size={14} />
{zh ? "清除配置" : "Clear"}
</button>
) : null}
</div>
</ProviderCard>
);
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function ModelManagement({
connectors,
locale,
onSaved,
}: ModelManagementProps) {
const zh = locale === "zh";
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const claudeConnector = connectors.find((c) => c.id === "claude_local");
const codexConnector = connectors.find((c) => c.id === "codex_local");
const geminiConnector = connectors.find((c) => c.id === "gemini_local");
const activeCompatSlots = COMPAT_SLOTS.filter((id) => {
const c = connectors.find((conn) => conn.id === id);
return (
c &&
(c.envBindingText.includes("ANTHROPIC_BASE_URL") ||
c.envBindingText.includes("ANTHROPIC_API_KEY"))
);
});
const nextAvailableSlot =
COMPAT_SLOTS.find((id) => !activeCompatSlots.includes(id)) ?? null;
const visibleCompatSlots = [...activeCompatSlots];
if (nextAvailableSlot && !visibleCompatSlots.includes(nextAvailableSlot)) {
visibleCompatSlots.push(nextAvailableSlot);
}
const toggle = (card: string) =>
setExpandedCard((prev) => (prev === card ? null : card));
return (
<div className="space-y-3">
{claudeConnector ? (
<NativeProviderCard
connector={claudeConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "claude_local"}
onToggle={() => toggle("claude_local")}
/>
) : null}
{codexConnector ? (
<NativeProviderCard
connector={codexConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "codex_local"}
onToggle={() => toggle("codex_local")}
/>
) : null}
{geminiConnector ? (
<NativeProviderCard
connector={geminiConnector}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === "gemini_local"}
onToggle={() => toggle("gemini_local")}
/>
) : null}
{visibleCompatSlots.map((slotId) => (
<CompatProviderCard
key={slotId}
slotId={slotId}
connector={connectors.find((c) => c.id === slotId) ?? null}
zh={zh}
onSaved={onSaved}
expanded={expandedCard === slotId}
onToggle={() => toggle(slotId)}
onCleared={() => setExpandedCard(null)}
/>
))}
</div> </div>
); );
} }
@@ -99,7 +99,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
{policies.map((policy) => { {policies.map((policy) => {
const util = getUtilization(policy.id); const util = getUtilization(policy.id);
const pct = util?.pct ?? 0; const pct = util?.pct ?? 0;
const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-yellow-600 dark:bg-yellow-400" : "bg-green-600 dark:bg-green-400"; const barColor = pct > 100 ? "bg-destructive" : pct > 80 ? "bg-warning" : "bg-success";
return ( return (
<ListRow key={policy.id} onClick={() => handleEdit(policy)}> <ListRow key={policy.id} onClick={() => handleEdit(policy)}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -110,7 +110,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
</span> </span>
<span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span> <span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span>
{policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-primary" /> : null} {policy.hardStopEnabled ? <ShieldCheck size={14} className="shrink-0 text-primary" /> : null}
{util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-yellow-600 dark:text-yellow-400" /> : null} {util?.hasIncident ? <Warning size={14} weight="fill" className="shrink-0 text-warning" /> : null}
</div> </div>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground"> <div className="mt-1 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}> <span className="font-[family-name:var(--font-mono)]" style={{ fontVariantNumeric: "tabular-nums" }}>
@@ -41,6 +41,23 @@ export function SettingsPane({ snapshot, secrets, companyId, onSaved }: { snapsh
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div> <div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={company.autoApproveHires} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: e.target.checked, reviewDeliverables: company.reviewDeliverables })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.autoApproveLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.autoApproveDesc")}</div></div></label></div>
<div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div> <div className="rounded-lg border border-border px-4 py-3"><label className="flex items-center gap-3"><input type="checkbox" checked={!company.reviewDeliverables} onChange={async (e) => { setActionError(null); try { unwrap(await window.agentCompany.saveCompany({ id: company.id, name: company.name, description: company.description, status: company.status, autoApproveHires: company.autoApproveHires, reviewDeliverables: !e.target.checked })); await onSaved() } catch (err) { setActionError(describeActionError(err)) } }} className="h-4 w-4 rounded accent-[color:var(--primary)]" /><div><div className="text-[13px] font-medium text-foreground">{t("exec.reviewDeliverablesLabel")}</div><div className="mt-0.5 text-[11px] text-muted-foreground">{t("exec.reviewDeliverablesDesc")}</div></div></label></div>
</div></div>) })()} </div></div>) })()}
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Keyboard Shortcuts</div>
<div className="space-y-1 border-y border-border py-3">
{([
[navigator.platform.includes("Mac") ? "⌘K" : "Ctrl+K", "Open command palette"],
[navigator.platform.includes("Mac") ? "⌘Enter" : "Ctrl+Enter", "Send message"],
["Escape", "Close modal / palette"],
["↑ / ↓", "Navigate palette results"],
["Enter", "Select palette result"],
["Tab", "Move focus between elements"],
] as const).map(([key, desc]) => (
<div key={key} className="flex items-center justify-between px-3 py-1.5 text-[12px]">
<span className="text-muted-foreground">{desc}</span>
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-[11px] text-foreground">{key}</kbd>
</div>
))}
</div>
</div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div> <div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.actions")}</div><div className="grid gap-3 md:grid-cols-2"><ActionButton label={t("exec.saveSettings")} tone="accent" onClick={() => void handleSaveSettings()} icon={CheckCircle} loading={loadingAction === "save"} /><ActionButton label={t("exec.checkForUpdates")} onClick={() => void handleCheckForUpdates()} icon={Download} loading={loadingAction === "check"} /><ActionButton label={t("exec.backupProfile")} onClick={() => void handleBackupProfile()} icon={ClockCounterClockwise} loading={loadingAction === "backup"} /><ActionButton label={t("exec.restoreProfile")} onClick={() => void handleRestoreProfile()} icon={ArrowClockwise} loading={loadingAction === "restore"} /></div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div> <div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackend")}</div><div className="border-y border-border py-3 text-[13px] text-muted-foreground">{snapshot.backend.secretBackend}</div></div>
<div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div> <div><div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secrets")} ({secrets.length})</div>{secrets.length === 0 ? <EmptyState title={t("exec.noSecretsStored")} /> : <div className="border-y border-border overflow-x-auto"><table className="w-full text-left text-[13px]"><thead><tr className="border-b border-border"><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretName")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretDescription")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretBackendCol")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.secretUpdated")}</th><th className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground" /></tr></thead><tbody>{secrets.map((s) => <tr key={s.id} className="border-b border-border last:border-b-0"><td className="px-4 py-3 font-medium text-foreground">{s.name}</td><td className="px-4 py-3 text-muted-foreground">{s.description || "\u2014"}</td><td className="px-4 py-3 font-mono text-muted-foreground">{s.backend}</td><td className="px-4 py-3 font-mono text-muted-foreground">{formatTime(s.updatedAt)}</td><td className="px-4 py-3"><ActionButton label="Delete" onClick={() => setConfirmDeleteSecretId(s.id)} icon={Trash} tone="danger" /></td></tr>)}</tbody></table></div>}</div>
@@ -121,7 +121,7 @@ export function SocialDraftPreview({ payloadJson, platform, isPending: _isPendin
) : null} ) : null}
{draft.scheduledAt ? ( {draft.scheduledAt ? (
<div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-yellow-600 dark:text-yellow-400"> <div className="flex items-center gap-1.5 text-[13px] font-[family-name:var(--font-mono)] text-warning">
<Clock size={14} /> <Clock size={14} />
Scheduled: {formatScheduledDate(draft.scheduledAt)} Scheduled: {formatScheduledDate(draft.scheduledAt)}
</div> </div>
@@ -52,7 +52,7 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
)} )}
{submitError ? <InlineNotice message={submitError} /> : null} {submitError ? <InlineNotice message={submitError} /> : null}
<div className="flex gap-2"> <div className="flex gap-2">
<input className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} /> <input aria-label={t("exec.sendMessage")} className="flex-1 rounded-md border border-border bg-card px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-[color:var(--primary)] focus:outline-none" placeholder={t("exec.sendMessage")} value={newComment} onChange={(e) => setNewComment(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void handleSubmit() } }} />
<ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} /> <ActionButton label={t("exec.send")} tone="accent" onClick={() => void handleSubmit()} icon={ArrowRight} loading={submitting} />
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import { ListChecks } from "@phosphor-icons/react" import { ListChecks } from "@phosphor-icons/react"
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types" import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
import { useT } from "../../i18n" import { useT } from "../../i18n"
@@ -20,15 +21,29 @@ export function TaskCreator({ companyId, goals, projects, agents, workspaces, on
try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) } try { unwrap(await window.agentCompany.saveTask({ companyId, projectId: projectId || null, goalId: goalId || null, title, description, assigneeAgentId: assigneeAgentId || null, workspaceId: workspaceId || null, priority: priority as TaskRecord["priority"], status: "todo" })); setTitle(""); setDescription(""); await onSaved() } catch (e) { setSubmitError(describeActionError(e)) } finally { setSubmitting(false) }
} }
const [showAdvanced, setShowAdvanced] = useState(false)
return ( return (
<div className="grid min-w-0 gap-4 md:grid-cols-2"> <div className="grid min-w-0 gap-4 md:grid-cols-2">
<Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} /> <Input label={t("exec.title")} value={title} onChange={setTitle} placeholder={t("exec.taskTitle")} />
<Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} /> <Select label={t("exec.priority")} value={priority} onChange={setPriority} options={["critical","high","medium","low"].map((v) => ({ value: v, label: v }))} />
<div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div> <div className="md:col-span-2"><TextArea label="Description" value={description} onChange={setDescription} placeholder="Describe the task" rows={5} /></div>
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} /> <div className="md:col-span-2">
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} /> <button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} /> <svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} /> {t("exec.goal")}, {t("exec.project")}, {t("exec.assignee")}, {t("exec.workspace")}
</button>
</div>
<AnimatePresence>
{showAdvanced ? (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden md:col-span-2 md:grid-cols-2">
<Select label={t("exec.goal")} value={goalId} onChange={setGoalId} options={[{ value: "", label: t("exec.noGoal") }, ...goals.map((g) => ({ value: g.id, label: g.title }))]} />
<Select label={t("exec.project")} value={projectId} onChange={setProjectId} options={[{ value: "", label: t("exec.noProject") }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />
<Select label={t("exec.assignee")} value={assigneeAgentId} onChange={setAssigneeAgentId} options={[{ value: "", label: t("exec.unassigned") }, ...agents.map((a) => ({ value: a.id, label: a.name }))]} />
<Select label={t("exec.workspace")} value={workspaceId} onChange={setWorkspaceId} options={[{ value: "", label: t("exec.noWorkspace") }, ...workspaces.map((w) => ({ value: w.id, label: w.name }))]} />
</motion.div>
) : null}
</AnimatePresence>
{submitError ? <div className="md:col-span-2"><InlineNotice message={submitError} /></div> : null} {submitError ? <div className="md:col-span-2"><InlineNotice message={submitError} /></div> : null}
<div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div> <div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div>
</div> </div>
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} /> <ConfirmDialog open={confirmDelete} title={t("exec.deleteTask")} message={`${t("exec.deleteTaskConfirm").replace("{0}", task.title)}`} errorMessage={deleteError} onConfirm={() => void handleDelete()} onCancel={() => { setDeleteError(null); setConfirmDelete(false) }} />
{budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive" : "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null} {budgetWarning > 0.8 && assignee ? <div className={`flex items-center gap-2 rounded-lg border px-4 py-3 text-[13px] font-medium ${budgetWarning > 1 ? "border-[color:var(--destructive)] bg-destructive-muted text-destructive" : "border-[color:var(--destructive)] bg-warning-muted text-warning"}`}><Warning size={16} weight="fill" />{assignee.name} has used {Math.round(budgetWarning * 100)}% of their monthly budget ({formatMoney(assignee.spentMonthlyUsd)} / {formatMoney(assignee.budgetMonthlyUsd)})</div> : null}
{actionError ? <InlineNotice message={actionError} /> : null} {actionError ? <InlineNotice message={actionError} /> : null}
{!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null} {!actionError && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
{editing ? ( {editing ? (
@@ -65,7 +65,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
</div> </div>
{task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null} {task.description ? <div className="text-[13px] leading-relaxed text-muted-foreground whitespace-pre-wrap">{task.description}</div> : null}
{task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div> {task.status === "in_review" && reviewer ? <div className="rounded-lg border border-[color:var(--primary)] bg-accent px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-primary"><Robot size={16} weight="fill" />Being reviewed by {reviewer.name}{reviewer.title ? ` (${reviewer.title})` : ""}</div><div className="mt-1 text-[13px] leading-relaxed text-primary">{assignee?.name} submitted this task for review. {reviewer.name} will approve, request revisions, or escalate.</div></div>
: needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-yellow-600 dark:text-yellow-400"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null} : needsGovernance ? <div className="rounded-lg border border-[color:var(--destructive)] bg-warning-muted px-4 py-3"><div className="flex items-center gap-2 text-[13px] font-semibold text-warning"><ShieldCheck size={16} weight="fill" />{task.status === "blocked" ? t("exec.governanceRequired") : t("exec.waitingOnReview")}</div><div className="mt-1 text-[13px] leading-relaxed text-muted-foreground">{task.status === "blocked" ? t("exec.blockedGovernance") : t("exec.reviewGovernance")}</div><div className="mt-3"><ActionButton label={task.status === "blocked" ? t("exec.createUnblockApproval") : t("exec.createReviewApproval")} onClick={() => void handleCreateApproval()} icon={ShieldCheck} loading={creatingApproval} /></div></div> : null}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <> {activeRun ? <ActionButton label={t("exec.cancelRun")} onClick={() => void onCancelRun(activeRun.id)} icon={XCircle} tone="danger" /> : <>
{["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />} {["todo","in_progress","blocked"].includes(task.status) && <ActionButton label={t("exec.run")} onClick={() => void onRun(task.id)} icon={Play} tone="accent" />}
@@ -80,19 +80,19 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
)} )}
{(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return ( {(() => { const subtasks = tasks.filter((t) => t.parentId === task.id); if (subtasks.length === 0) return null; return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-green-600 dark:text-green-400">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div> <div className="flex items-center gap-2"><ListChecks size={14} className="text-muted-foreground" /><span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.subtasks")} ({subtasks.length})</span><span className="text-[10px] text-success">{subtasks.filter((t) => t.status === "done").length}/{subtasks.length} done</span></div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-green-600 dark:bg-green-400 transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div> <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted"><div className="h-full rounded-full bg-success transition-all duration-300" style={{ width: `${subtasks.length > 0 ? (subtasks.filter((t) => t.status === "done").length / subtasks.length) * 100 : 0}%` }} /></div>
<div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return ( <div className="space-y-1">{subtasks.map((sub) => { const subAssignee = agentMap.get(sub.assigneeAgentId ?? ""); return (
<div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted"> <div key={sub.id} className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12px] bg-muted">
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-green-600 dark:bg-green-400" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]"}`} /> <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${sub.status === "done" ? "bg-success" : sub.status === "in_progress" ? "bg-primary" : sub.status === "blocked" ? "bg-destructive" : sub.status === "in_review" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"}`} />
<span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span> <span className={`flex-1 truncate ${sub.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>{sub.title}</span>
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} /> {subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
</div>) })}</div></div>) })()} </div>) })}</div></div>) })()}
<TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} /> <TaskComments taskId={task.id} companyId={task.companyId} agents={agents} comments={comments} onRefresh={onRefresh} />
{runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return ( {runs.length > 0 && <div className="space-y-2"><div className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("exec.runHistory")}</div><div className="space-y-2">{runs.slice(0, 8).map((run) => { const runAgent = agentMap.get(run.agentId); const isActive = run.status === "running" || run.status === "queued"; return (
<div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-red-100 dark:bg-red-900/50" : "border-border bg-muted"}`}> <div key={run.id} className={`rounded-lg border px-3 py-2.5 ${isActive ? "border-[color:var(--primary)] bg-success-muted" : run.status === "failed" || run.status === "timed_out" ? "border-[color:var(--destructive)] border-opacity-30 bg-destructive-muted" : "border-border bg-muted"}`}>
<div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-green-600 dark:bg-green-400" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div> <div className="flex items-center justify-between gap-2"><div className="min-w-0 flex-1"><div className="flex items-center gap-2">{isActive ? <span className="h-2 w-2 animate-pulse rounded-full bg-success" /> : null}<span className="truncate text-[13px] font-medium text-foreground">{run.summary || (isActive ? "Running..." : "Completed run")}</span></div><div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">{runAgent ? <span>{runAgent.name}</span> : null}<span>{run.connectorId.replace("_local", "")}</span>{run.model ? <span className="font-mono">{run.model}</span> : null}<span>{formatTime(run.startedAt)}</span>{run.costUsd != null && run.costUsd > 0 ? <span className="font-mono tabular-nums">{formatMoney(run.costUsd)}</span> : null}</div></div><StatusPill status={run.status} /></div>
{run.errorMessage ? <div className="mt-1.5 rounded bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null} {run.errorMessage ? <div className="mt-1.5 rounded bg-destructive-muted px-2 py-1 text-[11px] text-destructive break-words">{run.errorMessage}</div> : null}
</div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>} </div>) })}{runs.length > 8 && <div className="text-[11px] text-muted-foreground text-center">+{runs.length - 8} more runs</div>}</div></div>}
</div> </div>
) )
@@ -27,11 +27,11 @@ export const ENTITY_TYPE_OPTIONS = [
export function WorkspaceStatePill({ state }: { state: string }) { export function WorkspaceStatePill({ state }: { state: string }) {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
ready: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50", ready: "text-success bg-success-muted",
busy: "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50", busy: "text-warning bg-warning-muted",
blocked: "text-destructive bg-red-100 dark:bg-red-900/50", blocked: "text-destructive bg-destructive-muted",
missing: "text-muted-foreground bg-muted", missing: "text-muted-foreground bg-muted",
error: "text-destructive bg-red-100 dark:bg-red-900/50", error: "text-destructive bg-destructive-muted",
} }
return ( return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${colorMap[state] ?? colorMap.missing}`}> <span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${colorMap[state] ?? colorMap.missing}`}>
@@ -41,6 +41,6 @@ export function WorkspaceStatePill({ state }: { state: string }) {
} }
export function PriorityDot({ priority }: { priority: string }) { export function PriorityDot({ priority }: { priority: string }) {
const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-yellow-600 dark:bg-yellow-400" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]" const color = priority === "critical" ? "bg-destructive" : priority === "high" ? "bg-warning" : priority === "low" ? "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} /> return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} aria-label={`${priority} priority`} />
} }
@@ -126,8 +126,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
const ag = candidate.agent; const ag = candidate.agent;
return ( return (
<div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3"> <div key={a?.id ?? ag?.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-yellow-100 dark:bg-yellow-900/50 flex items-center justify-center"> <div className="flex-shrink-0 mt-0.5 h-8 w-8 rounded-full bg-warning-muted flex items-center justify-center">
<UserPlus size={16} className="text-yellow-600 dark:text-yellow-400" /> <UserPlus size={16} className="text-warning" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -247,7 +247,7 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
</div> </div>
<div className="rounded-lg px-4 py-3 border border-border bg-card"> <div className="rounded-lg px-4 py-3 border border-border bg-card">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.pendingHires")}</div> <div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.pendingHires")}</div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400 mt-1">{agents.filter(a => a.status === "pending_approval").length}</div> <div className="text-2xl font-bold text-warning mt-1">{agents.filter(a => a.status === "pending_approval").length}</div>
</div> </div>
<div className="rounded-lg px-4 py-3 border border-border bg-card"> <div className="rounded-lg px-4 py-3 border border-border bg-card">
<div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.departments")}</div> <div className="text-[10px] uppercase text-muted-foreground tracking-wider">{t("hiring.departments")}</div>
@@ -278,8 +278,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
<span className="text-[10px] text-muted-foreground">{t("hiring.active")}</span> <span className="text-[10px] text-muted-foreground">{t("hiring.active")}</span>
{pending > 0 && ( {pending > 0 && (
<> <>
<span className="text-sm font-bold text-yellow-600 dark:text-yellow-400">+{pending}</span> <span className="text-sm font-bold text-warning">+{pending}</span>
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">{t("hiring.pendingApproval")}</span> <span className="text-[10px] text-warning">{t("hiring.pendingApproval")}</span>
</> </>
)} )}
</div> </div>
@@ -24,12 +24,12 @@ export function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | nu
export function stageColor(stage: HiringStage): string { export function stageColor(stage: HiringStage): string {
switch (stage) { switch (stage) {
case "requisition": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"; case "requisition": return "bg-warning-muted text-warning";
case "pending_approval": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"; case "pending_approval": return "bg-warning-muted text-warning";
case "approved": return "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"; case "approved": return "bg-success-muted text-success";
case "onboarding": return "bg-accent text-primary"; case "onboarding": return "bg-accent text-primary";
case "active": return "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"; case "active": return "bg-success-muted text-success";
case "rejected": return "bg-red-100 dark:bg-red-900/50 text-destructive"; case "rejected": return "bg-destructive-muted text-destructive";
} }
} }
@@ -60,7 +60,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
</div> </div>
<div className="flex gap-4 text-[12px]"> <div className="flex gap-4 text-[12px]">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-green-600 dark:bg-green-400" /> <span className="h-2.5 w-2.5 rounded-full bg-success" />
<span className="text-muted-foreground">{metrics.successfulRuns} succeeded</span> <span className="text-muted-foreground">{metrics.successfulRuns} succeeded</span>
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -102,11 +102,11 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
<span className="text-[12px] font-medium text-foreground">{am.agentName}</span> <span className="text-[12px] font-medium text-foreground">{am.agentName}</span>
</div> </div>
</td> </td>
<td className="px-3 py-2.5 text-right text-[12px] font-semibold text-green-600 dark:text-green-400">{am.tasksCompleted}</td> <td className="px-3 py-2.5 text-right text-[12px] font-semibold text-success">{am.tasksCompleted}</td>
<td className="px-3 py-2.5 text-right"> <td className="px-3 py-2.5 text-right">
<span className={`text-[12px] font-semibold ${ <span className={`text-[12px] font-semibold ${
am.successRate >= 80 ? "text-green-600 dark:text-green-400" am.successRate >= 80 ? "text-success"
: am.successRate >= 50 ? "text-yellow-600 dark:text-yellow-400" : am.successRate >= 50 ? "text-warning"
: "text-destructive" : "text-destructive"
}`}> }`}>
{am.successRate}% {am.successRate}%
@@ -43,7 +43,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="text-[14px] font-medium text-foreground">{report.summary}</div> <div className="text-[14px] font-medium text-foreground">{report.summary}</div>
<div className="mt-3 flex gap-4"> <div className="mt-3 flex gap-4">
<div className="text-center"> <div className="text-center">
<div className="text-[18px] font-bold tabular-nums text-green-600 dark:text-green-400">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div> <div className="text-[18px] font-bold tabular-nums text-success">{report.agentReports.filter(r => r.tasksCompleted24h > 0).length}</div>
<div className="text-[10px] text-muted-foreground">{t("standup.productive")}</div> <div className="text-[10px] text-muted-foreground">{t("standup.productive")}</div>
</div> </div>
<div className="h-10 w-px bg-[color:var(--border)]" /> <div className="h-10 w-px bg-[color:var(--border)]" />
@@ -53,7 +53,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div> </div>
<div className="h-10 w-px bg-[color:var(--border)]" /> <div className="h-10 w-px bg-[color:var(--border)]" />
<div className="text-center"> <div className="text-center">
<div className="text-[18px] font-bold tabular-nums text-yellow-600 dark:text-yellow-400">{report.pendingApprovals}</div> <div className="text-[18px] font-bold tabular-nums text-warning">{report.pendingApprovals}</div>
<div className="text-[10px] text-muted-foreground">{t("standup.pendingApprovals")}</div> <div className="text-[10px] text-muted-foreground">{t("standup.pendingApprovals")}</div>
</div> </div>
<div className="h-10 w-px bg-[color:var(--border)]" /> <div className="h-10 w-px bg-[color:var(--border)]" />
@@ -71,7 +71,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("standup.highlights")}</div> <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{t("standup.highlights")}</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{report.companyHighlights.map((h, i) => ( {report.companyHighlights.map((h, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-green-100 dark:bg-green-900/50 px-3 py-2 text-[12px] text-green-600 dark:text-green-400"> <div key={i} className="flex items-center gap-2 rounded-lg bg-success-muted px-3 py-2 text-[12px] text-success">
<CheckCircle size={14} weight="fill" /> <CheckCircle size={14} weight="fill" />
{h} {h}
</div> </div>
@@ -85,7 +85,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-destructive">Blockers</div> <div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-destructive">Blockers</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
{report.blockers.map((b, i) => ( {report.blockers.map((b, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg bg-red-100 dark:bg-red-900/50 px-3 py-2 text-[12px] text-destructive"> <div key={i} className="flex items-center gap-2 rounded-lg bg-destructive-muted px-3 py-2 text-[12px] text-destructive">
<Warning size={14} weight="fill" /> <Warning size={14} weight="fill" />
{b} {b}
</div> </div>
@@ -106,16 +106,16 @@ export function StandupView({ report, onNavigate }: StandupProps) {
> >
<div className="relative"> <div className="relative">
<div className={`flex h-10 w-10 items-center justify-center rounded-full text-[12px] font-bold ${ <div className={`flex h-10 w-10 items-center justify-center rounded-full text-[12px] font-bold ${
ar.status === "running" ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" ar.status === "running" ? "bg-success-muted text-success"
: ar.status === "error" ? "bg-red-100 dark:bg-red-900/50 text-destructive" : ar.status === "error" ? "bg-destructive-muted text-destructive"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`}> }`}>
{getAgentInitials(ar.agentName)} {getAgentInitials(ar.agentName)}
</div> </div>
{ar.status === "running" ? ( {ar.status === "running" ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3"> <span className="absolute -bottom-0.5 -right-0.5 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-600 dark:bg-green-400 opacity-40" style={{ willChange: "transform" }} /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-success opacity-40" style={{ willChange: "transform" }} />
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-600 dark:bg-green-400" /> <span className="relative inline-flex h-2 w-2 rounded-full bg-success" />
</span> </span>
) : null} ) : null}
</div> </div>
@@ -126,7 +126,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
</div> </div>
<div className="mt-1 flex flex-wrap gap-3 text-[11px] text-muted-foreground"> <div className="mt-1 flex flex-wrap gap-3 text-[11px] text-muted-foreground">
{ar.tasksInProgress > 0 ? <span className="text-primary">{ar.tasksInProgress} in progress</span> : null} {ar.tasksInProgress > 0 ? <span className="text-primary">{ar.tasksInProgress} in progress</span> : null}
{ar.tasksCompleted24h > 0 ? <span className="text-green-600 dark:text-green-400">{ar.tasksCompleted24h} done</span> : null} {ar.tasksCompleted24h > 0 ? <span className="text-success">{ar.tasksCompleted24h} done</span> : null}
{ar.tasksBlocked > 0 ? <span className="text-destructive">{ar.tasksBlocked} blocked</span> : null} {ar.tasksBlocked > 0 ? <span className="text-destructive">{ar.tasksBlocked} blocked</span> : null}
{ar.costLast24h > 0 ? <span className="tabular-nums">${ar.costLast24h.toFixed(2)}</span> : null} {ar.costLast24h > 0 ? <span className="tabular-nums">${ar.costLast24h.toFixed(2)}</span> : null}
</div> </div>
@@ -33,8 +33,8 @@ export function CompactMetric({ label, value, tone = "default", subtitle }: {
}) { }) {
const toneMap = { const toneMap = {
default: "text-foreground", default: "text-foreground",
success: "text-green-600 dark:text-green-400", success: "text-success",
warn: "text-yellow-600 dark:text-yellow-400", warn: "text-warning",
danger: "text-destructive", danger: "text-destructive",
accent: "text-primary", accent: "text-primary",
}; };
@@ -56,7 +56,7 @@ export function RunProgressBar({ succeeded, failed, total }: { succeeded: number
return ( return (
<div className="flex h-3 overflow-hidden rounded-full bg-muted"> <div className="flex h-3 overflow-hidden rounded-full bg-muted">
<motion.div <motion.div
className="h-full bg-green-600 dark:bg-green-400" className="h-full bg-success"
initial={{ width: 0 }} initial={{ width: 0 }}
animate={{ width: `${successPct}%` }} animate={{ width: `${successPct}%` }}
transition={{ duration: 0.8, ease: "easeOut" }} transition={{ duration: 0.8, ease: "easeOut" }}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Robot } from "@phosphor-icons/react"; import { Robot } from "@phosphor-icons/react";
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types"; import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
import { isConnectorExecutionReady } from "@shared/connector-policy"; import { isConnectorExecutionReady } from "@shared/connector-policy";
@@ -65,11 +66,13 @@ export function AgentCreator({
} }
}, [executionReadyConnectors, connectorId]); }, [executionReadyConnectors, connectorId]);
const [showAdvanced, setShowAdvanced] = useState(false);
return ( return (
<div className="grid min-w-0 gap-4"> <div className="grid min-w-0 gap-4">
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" /> <Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" hint="Display name shown in org chart and messages" />
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" /> <Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" hint="Determines which tasks the agent can handle" />
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" /> <Input label="Title" value={title} onChange={setTitle} placeholder="Title" hint="Job title shown under the name" />
<Select <Select
label="Connector" label="Connector"
value={connectorId} value={connectorId}
@@ -77,7 +80,7 @@ export function AgentCreator({
options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))} options={executionReadyConnectors.map((connector) => ({ value: connector.id, label: connector.id.startsWith("claude_compat") && connector.notes ? connector.notes : connector.label }))}
/> />
{executionReadyConnectors.length === 0 ? ( {executionReadyConnectors.length === 0 ? (
<p className="text-[12px] text-yellow-600 dark:text-yellow-400"> <p className="text-[12px] text-warning">
No execution-ready connector is available. Fix connector health before requesting hires. No execution-ready connector is available. Fix connector health before requesting hires.
</p> </p>
) : null} ) : null}
@@ -102,31 +105,42 @@ export function AgentCreator({
]} ]}
/> />
) : null} ) : null}
<Select <button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
label="Workspace" <svg width="10" height="10" viewBox="0 0 10 10" className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}><path d="M3 1l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>
value={workspaceId} Advanced settings
onChange={setWorkspaceId} </button>
options={[ <AnimatePresence>
{ value: "", label: "No workspace" }, {showAdvanced ? (
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })), <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }} className="grid gap-4 overflow-hidden">
]} <Select
/> label="Workspace"
<Select value={workspaceId}
label="Reports to" onChange={setWorkspaceId}
value={reportsTo} options={[
onChange={setReportsTo} { value: "", label: "No workspace" },
options={[ ...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
{ value: "", label: "No manager" }, ]}
...agents.map((a) => ({ value: a.id, label: a.name })), />
]} <Select
/> label="Reports to"
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" /> value={reportsTo}
<TextArea onChange={setReportsTo}
label="Capabilities" options={[
value={capabilities} { value: "", label: "No manager" },
onChange={setCapabilities} ...agents.map((a) => ({ value: a.id, label: a.name })),
placeholder="What this agent does" ]}
/> />
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" hint="Spending cap per month; agent pauses when exceeded" />
<TextArea
label="Capabilities"
value={capabilities}
onChange={setCapabilities}
placeholder="What this agent does"
hint="Skills and domain knowledge injected into the agent's system prompt"
/>
</motion.div>
) : null}
</AnimatePresence>
<p className="text-[12px] text-muted-foreground"> <p className="text-[12px] text-muted-foreground">
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record. New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
</p> </p>
@@ -155,7 +155,7 @@ export function AgentEditor({
options={connectorOptions} options={connectorOptions}
/> />
{selectedConnectorIssue ? ( {selectedConnectorIssue ? (
<p className="text-[12px] text-yellow-600 dark:text-yellow-400"> <p className="text-[12px] text-warning">
{selectedConnectorIssue} {selectedConnectorIssue}
</p> </p>
) : null} ) : null}
@@ -76,7 +76,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
<div ref={containerRef} className="relative min-h-[400px] h-[clamp(400px,60vh,720px)] w-full overflow-hidden rounded-lg border border-border bg-background" style={{ cursor: dragging ? "grabbing" : "grab" }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}> <div ref={containerRef} className="relative min-h-[400px] h-[clamp(400px,60vh,720px)] w-full overflow-hidden rounded-lg border border-border bg-background" style={{ cursor: dragging ? "grabbing" : "grab" }} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onWheel={handleWheel}>
<div className="absolute right-3 top-3 z-10 flex flex-col gap-1"> <div className="absolute right-3 top-3 z-10 flex flex-col gap-1">
{[{ label: "+", ariaLabel: "Zoom in", action: zoomIn }, { label: "\u2212", ariaLabel: "Zoom out", action: zoomOut }, { label: "Fit", ariaLabel: "Fit chart to screen", action: fitToScreen }].map((btn) => ( {[{ label: "+", ariaLabel: "Zoom in", action: zoomIn }, { label: "\u2212", ariaLabel: "Zoom out", action: zoomOut }, { label: "Fit", ariaLabel: "Fit chart to screen", action: fitToScreen }].map((btn) => (
<button key={btn.ariaLabel} className="flex h-7 w-7 items-center justify-center rounded-lg border border-border bg-card text-[12px] text-foreground transition hover:bg-muted" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button> <button key={btn.ariaLabel} className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-card text-[12px] text-foreground transition hover:bg-muted" onClick={btn.action} aria-label={btn.ariaLabel}>{btn.label}</button>
))} ))}
</div> </div>
<svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}> <svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}>
@@ -103,11 +103,11 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
const isPending = node.agent.status === "pending_approval"; const isPending = node.agent.status === "pending_approval";
const isCEO = node.children.length > 0 && !node.agent.reportsTo; const isCEO = node.children.length > 0 && !node.agent.reportsTo;
return ( return (
<div key={node.agent.id} data-org-card className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)}> <div key={node.agent.id} data-org-card role="button" tabIndex={0} aria-label={`${node.agent.name}, ${node.agent.title || node.agent.role}, ${node.agent.status}`} className={`absolute cursor-pointer select-none rounded-lg border bg-card shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${isRunning ? "border-[color:var(--primary)] shadow-[0_0_12px_rgba(74,222,128,0.15)]" : isError ? "border-[color:var(--destructive)] border-opacity-50" : isPending ? "border-[color:var(--destructive)] border-dashed" : "border-border hover:border-border"}`} style={{ left: node.x, top: node.y, width: CARD_W, minHeight: CARD_H }} onClick={() => onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}>
<div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-yellow-600 dark:bg-yellow-400" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} /> <div className={`rounded-t-[7px] ${isCEO ? "h-[4px] bg-primary" : node.children.length > 0 ? "h-[3px] bg-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
<div className="flex items-center gap-3 px-4 py-3"> <div className="flex items-center gap-3 px-4 py-3">
<div className="relative shrink-0"> <div className="relative shrink-0">
<div className={`flex h-11 w-11 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" : isError ? "bg-red-100 dark:bg-red-900/50 text-destructive" : "bg-muted text-muted-foreground"}`}>{initials}</div> <div className={`flex h-11 w-11 items-center justify-center rounded-full text-[13px] font-bold ${isActive ? "bg-success-muted text-success" : isError ? "bg-destructive-muted text-destructive" : "bg-muted text-muted-foreground"}`}>{initials}</div>
{isRunning ? ( {isRunning ? (
<span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5" aria-hidden="true"> <span className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-40" style={{ backgroundColor: dotColor, willChange: "transform" }} /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full opacity-40" style={{ backgroundColor: dotColor, willChange: "transform" }} />
@@ -123,7 +123,7 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
<span className="mt-0.5 text-[11px] leading-tight text-muted-foreground">{node.agent.title || node.agent.role}</span> <span className="mt-0.5 text-[11px] leading-tight text-muted-foreground">{node.agent.title || node.agent.role}</span>
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center gap-2">
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{connectorLabel}</span> <span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{connectorLabel}</span>
{node.agent.heartbeatEnabled ? <Heartbeat size={10} className="text-green-600 dark:text-green-400" /> : null} {node.agent.heartbeatEnabled ? <Heartbeat size={10} className="text-success" /> : null}
</div> </div>
</div> </div>
</div> </div>
@@ -10,7 +10,7 @@ export function BudgetBar({ spent, budget }: { spent: number; budget: number })
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[color:var(--border)]"> <div className="h-1.5 w-20 overflow-hidden rounded-full bg-[color:var(--border)]">
<div <div
className={`h-full rounded-full transition-all ${overBudget ? "bg-destructive" : "bg-green-600 dark:bg-green-400"}`} className={`h-full rounded-full transition-all ${overBudget ? "bg-destructive" : "bg-success"}`}
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
@@ -31,7 +31,7 @@ export function ConfirmDelete({
onCancel: () => void; onCancel: () => void;
}) { }) {
return ( return (
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3"> <div className="flex items-center gap-3 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3">
<span className="text-[13px] text-destructive">Delete {entityLabel}?</span> <span className="text-[13px] text-destructive">Delete {entityLabel}?</span>
<ActionButton label="Yes, delete" tone="danger" onClick={onConfirm} icon={Trash} /> <ActionButton label="Yes, delete" tone="danger" onClick={onConfirm} icon={Trash} />
<ActionButton label="Cancel" onClick={onCancel} icon={XCircle} /> <ActionButton label="Cancel" onClick={onCancel} icon={XCircle} />
@@ -118,7 +118,7 @@ export function HeartbeatSection({
} }
/> />
{persistedConnectorIssue ? ( {persistedConnectorIssue ? (
<p className="mt-2 text-[12px] text-yellow-600 dark:text-yellow-400"> <p className="mt-2 text-[12px] text-warning">
{persistedConnectorIssue} Save a ready connector before waking this agent again. {persistedConnectorIssue} Save a ready connector before waking this agent again.
</p> </p>
) : null} ) : null}
+2 -2
View File
@@ -82,7 +82,7 @@ export function RunDetail({
<DetailRow label="Cost">{formatMoney(selectedRun.costUsd)}</DetailRow> <DetailRow label="Cost">{formatMoney(selectedRun.costUsd)}</DetailRow>
</div> </div>
{selectedRun.errorMessage ? ( {selectedRun.errorMessage ? (
<div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive"> <div className="mt-4 rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{selectedRun.errorMessage} {selectedRun.errorMessage}
</div> </div>
) : null} ) : null}
@@ -118,7 +118,7 @@ export function RunDetail({
)} )}
> >
{logState.error ? ( {logState.error ? (
<div className="rounded-lg border border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 px-4 py-3 text-[13px] text-destructive"> <div className="rounded-lg border border-[color:var(--destructive)] bg-destructive-muted px-4 py-3 text-[13px] text-destructive">
{logState.error} {logState.error}
</div> </div>
) : null} ) : null}
@@ -28,7 +28,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
<div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground"> <div className="mt-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">@{account.accountName}</span> <span className="truncate">@{account.accountName}</span>
{account.requireApproval ? ( {account.requireApproval ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400"> <span className="flex items-center gap-1 text-warning">
<ShieldCheck size={10} /> <ShieldCheck size={10} />
Approval required Approval required
</span> </span>
@@ -58,16 +58,16 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${ <span className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-semibold ${
isLoggedIn isLoggedIn
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" ? "bg-success-muted text-success"
: account.status === "login_required" : account.status === "login_required"
? "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400" ? "bg-warning-muted text-warning"
: account.status === "suspended" : account.status === "suspended"
? "bg-red-100 dark:bg-red-900/50 text-destructive" ? "bg-destructive-muted text-destructive"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`}> }`}>
<span className={`h-1.5 w-1.5 rounded-full ${ <span className={`h-1.5 w-1.5 rounded-full ${
isLoggedIn ? "bg-green-600 dark:bg-green-400" isLoggedIn ? "bg-success"
: account.status === "login_required" ? "bg-yellow-600 dark:bg-yellow-400" : account.status === "login_required" ? "bg-warning"
: account.status === "suspended" ? "bg-destructive" : account.status === "suspended" ? "bg-destructive"
: "bg-[color:var(--muted-foreground)]" : "bg-[color:var(--muted-foreground)]"
}`} /> }`} />
@@ -89,7 +89,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
type="button" type="button"
onClick={onDelete} onClick={onDelete}
aria-label="Delete account" aria-label="Delete account"
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition hover:bg-red-100 dark:bg-red-900/50 hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100" className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition hover:bg-destructive-muted hover:text-destructive group-hover:opacity-100 group-focus-within:opacity-100"
> >
<Trash size={14} /> <Trash size={14} />
</button> </button>
@@ -40,7 +40,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2.5 text-[12px]" className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2.5 text-[12px]"
> >
<Icon size={16} className={ <Icon size={16} className={
isSuccess ? "text-green-600 dark:text-green-400" isSuccess ? "text-success"
: isFailed ? "text-destructive" : isFailed ? "text-destructive"
: isRunning ? "text-primary" : isRunning ? "text-primary"
: "text-muted-foreground" : "text-muted-foreground"
@@ -94,7 +94,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
<button <button
type="button" type="button"
onClick={() => void onCancelAction(action.id)} onClick={() => void onCancelAction(action.id)}
className="inline-flex items-center gap-1 rounded-[5px] bg-red-100 dark:bg-red-900/50 px-2 py-1 text-[10px] font-medium text-destructive transition hover:brightness-95" className="inline-flex items-center gap-1 rounded-[5px] bg-destructive-muted px-2 py-1 text-[10px] font-medium text-destructive transition hover:brightness-95"
> >
<ClockCounterClockwise size={10} /> <ClockCounterClockwise size={10} />
Cancel Cancel
@@ -106,10 +106,10 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
{timeAgo(action.createdAt)} {timeAgo(action.createdAt)}
</div> </div>
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${ <span className={`shrink-0 rounded-full px-2 py-0.5 text-[9px] font-bold ${
isSuccess ? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" isSuccess ? "bg-success-muted text-success"
: isFailed ? "bg-red-100 dark:bg-red-900/50 text-destructive" : isFailed ? "bg-destructive-muted text-destructive"
: isRunning ? "bg-accent text-primary" : isRunning ? "bg-accent text-primary"
: action.status === "approval_required" ? "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400" : action.status === "approval_required" ? "bg-warning-muted text-warning"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
}`}> }`}>
{action.status.replace("_", " ")} {action.status.replace("_", " ")}
+98 -59
View File
@@ -1,8 +1,11 @@
import { Component, useEffect, useRef, type ElementType, type ErrorInfo, type ReactNode } from "react"; import { Component, useEffect, useRef, type ElementType, type ErrorInfo, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
const EASE = [0.16, 1, 0.3, 1] as const;
export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) { export function Badge({ count, tone = "default" }: { count: number; tone?: "default" | "warn" | "danger" }) {
if (count <= 0) return null; if (count <= 0) return null;
const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-yellow-600 dark:bg-yellow-400 text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground"; const toneClass = tone === "danger" ? "bg-destructive text-primary-foreground" : tone === "warn" ? "bg-warning text-primary-foreground" : "bg-[color:var(--muted-foreground)] text-primary-foreground";
return ( return (
<span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}> <span className={`ml-auto inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[10px] font-bold ${toneClass}`}>
{count > 99 ? "99+" : count} {count > 99 ? "99+" : count}
@@ -43,7 +46,7 @@ export function SectionButton({
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} /> <Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
{badge ? ( {badge ? (
<span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${ <span className={`absolute right-1 top-1 h-[6px] w-[6px] rounded-full ${
badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-yellow-600 dark:bg-yellow-400" : "bg-[color:var(--muted-foreground)]" badgeTone === "danger" ? "bg-destructive" : badgeTone === "warn" ? "bg-warning" : "bg-[color:var(--muted-foreground)]"
}`} /> }`} />
) : null} ) : null}
</button> </button>
@@ -59,7 +62,7 @@ export function SectionButton({
}`} }`}
onClick={onClick} onClick={onClick}
> >
{active ? <span className="absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary" aria-hidden="true" /> : null} <span className={`absolute left-0 top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-primary transition-all duration-200 ${active ? "opacity-100 scale-y-100" : "opacity-0 scale-y-0"}`} aria-hidden="true" />
<Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} /> <Icon size={17} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span> <span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
{badge ? <Badge count={badge} tone={badgeTone} /> : null} {badge ? <Badge count={badge} tone={badgeTone} /> : null}
@@ -120,32 +123,46 @@ export function Modal({
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onClose}> <AnimatePresence>
<div {open ? (
ref={trapRef} <motion.div
role="dialog" initial={{ opacity: 0 }}
aria-modal="true" animate={{ opacity: 1 }}
aria-label={title} exit={{ opacity: 0 }}
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl" transition={{ duration: 0.18, ease: EASE }}
style={{ maxWidth: width, maxHeight: "85vh" }} className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
onClick={(e) => e.stopPropagation()} onClick={onClose}
> >
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5"> <motion.div
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2> ref={trapRef}
<button role="dialog"
type="button" aria-modal="true"
className="ml-3 shrink-0 cursor-pointer rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground" aria-label={title}
onClick={(e) => { e.stopPropagation(); onClose(); }} initial={{ scale: 0.96, opacity: 0, y: 8 }}
aria-label="Close" animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.96, opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: EASE }}
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
style={{ maxWidth: width, maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
> >
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg> <div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
</button> <h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
</div> <button
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div> type="button"
</div> className="ml-3 flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
</div> onClick={(e) => { e.stopPropagation(); onClose(); }}
aria-label="Close"
>
<svg width="16" height="16" viewBox="0 0 14 14" fill="none"><path d="M1 1l12 12M13 1L1 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
</div>
<div className="min-h-0 min-w-0 overflow-y-auto overflow-x-auto px-5 py-4" style={{ overflowWrap: "break-word", wordBreak: "break-word" }}>{children}</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
); );
} }
@@ -166,6 +183,7 @@ export function Input({
readOnly = false, readOnly = false,
disabled = false, disabled = false,
label, label,
hint,
id, id,
ariaLabel, ariaLabel,
}: { }: {
@@ -176,6 +194,7 @@ export function Input({
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
hint?: string;
id?: string; id?: string;
ariaLabel?: string; ariaLabel?: string;
}) { }) {
@@ -195,7 +214,10 @@ export function Input({
if (label) { if (label) {
return ( return (
<label className="block"> <label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div> <div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{input} {input}
</label> </label>
); );
@@ -209,6 +231,7 @@ export function TextArea({
placeholder, placeholder,
rows = 4, rows = 4,
label, label,
hint,
readOnly = false, readOnly = false,
disabled = false, disabled = false,
}: { }: {
@@ -217,6 +240,7 @@ export function TextArea({
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
label?: string; label?: string;
hint?: string;
readOnly?: boolean; readOnly?: boolean;
disabled?: boolean; disabled?: boolean;
}) { }) {
@@ -235,7 +259,10 @@ export function TextArea({
if (label) { if (label) {
return ( return (
<label className="block"> <label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div> <div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{textarea} {textarea}
</label> </label>
); );
@@ -249,6 +276,7 @@ export function Select({
options, options,
compact = false, compact = false,
label, label,
hint,
disabled = false, disabled = false,
}: { }: {
value: string; value: string;
@@ -256,6 +284,7 @@ export function Select({
options: Array<{ value: string; label: string }>; options: Array<{ value: string; label: string }>;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
hint?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {
const select = ( const select = (
@@ -279,7 +308,10 @@ export function Select({
if (label) { if (label) {
return ( return (
<label className="block"> <label className="block">
<div className="mb-1.5 text-[12px] font-medium text-muted-foreground">{label}</div> <div className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
{hint ? <span title={hint} className="cursor-help text-[10px] text-muted-foreground/60 hover:text-muted-foreground">(?)</span> : null}
</div>
{select} {select}
</label> </label>
); );
@@ -312,7 +344,7 @@ export function ActionButton({
return ( return (
<button <button
className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition duration-150 active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`} className={`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 inline-flex items-center gap-2 rounded-lg border px-4 py-2.5 text-[12px] font-bold transition-all duration-150 active:scale-[0.97] active:translate-y-px ${toneClass} ${isDisabled ? "pointer-events-none opacity-60" : ""} ${loading ? "scale-[0.98]" : ""}`}
onClick={onClick} onClick={onClick}
disabled={isDisabled} disabled={isDisabled}
aria-busy={loading} aria-busy={loading}
@@ -336,18 +368,18 @@ export function StatusPill({ status }: { status: string }) {
const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status); const warning = ["auth_required", "degraded", "pending", "in_review", "todo", "queued", "not_installed"].includes(status);
const checking = status === "detected"; const checking = status === "detected";
const toneClass = positive const toneClass = positive
? "text-green-600 dark:text-green-400" ? "text-success"
: negative : negative
? "text-destructive" ? "text-destructive"
: warning : warning
? "text-yellow-600 dark:text-yellow-400" ? "text-warning"
: "text-muted-foreground"; : "text-muted-foreground";
const dotClass = positive const dotClass = positive
? "bg-green-600 dark:bg-green-400" ? "bg-success"
: negative : negative
? "bg-destructive" ? "bg-destructive"
: warning : warning
? "bg-yellow-600 dark:bg-yellow-400" ? "bg-warning"
: "bg-[color:var(--muted-foreground)]"; : "bg-[color:var(--muted-foreground)]";
const displayLabel = checking ? "checking" : status.replaceAll("_", " "); const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
@@ -374,10 +406,10 @@ export function InlineNotice({
action?: ReactNode; action?: ReactNode;
}) { }) {
const toneClass = tone === "success" const toneClass = tone === "success"
? "border-[color:var(--primary)] bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400" ? "border-[color:var(--primary)] bg-success-muted text-success"
: tone === "warn" : tone === "warn"
? "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400" ? "border-[color:var(--warning)] bg-warning-muted text-warning"
: "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive"; : "border-[color:var(--destructive)] bg-destructive-muted text-destructive";
return ( return (
<div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}> <div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
{action ? ( {action ? (
@@ -409,26 +441,33 @@ export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, o
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, onCancel]); }, [open, onCancel]);
if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}> <AnimatePresence>
<div {open ? (
ref={trapRef} <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: EASE }} className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}>
role="dialog" <motion.div
aria-modal="true" ref={trapRef}
aria-labelledby="confirm-dialog-title" role="dialog"
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg" aria-modal="true"
onClick={e => e.stopPropagation()} aria-labelledby="confirm-dialog-title"
> initial={{ scale: 0.96, opacity: 0 }}
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div> animate={{ scale: 1, opacity: 1 }}
<div className="text-[13px] text-muted-foreground mb-6">{message}</div> exit={{ scale: 0.96, opacity: 0 }}
{errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null} transition={{ duration: 0.18, ease: EASE }}
<div className="flex gap-3 justify-end"> className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
<ActionButton label="Cancel" onClick={onCancel} /> onClick={e => e.stopPropagation()}
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} /> >
</div> <div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
</div> <div className="text-[13px] text-muted-foreground mb-6">{message}</div>
</div> {errorMessage ? <div className="mb-4"><InlineNotice message={errorMessage} /></div> : null}
<div className="flex gap-3 justify-end">
<ActionButton label="Cancel" onClick={onCancel} />
<ActionButton label="Confirm" tone="danger" onClick={onConfirm} />
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
); );
} }
@@ -442,11 +481,11 @@ export function EmptyState({
action?: ReactNode; action?: ReactNode;
}) { }) {
return ( return (
<div className="px-1 py-8 text-center"> <motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.22, ease: EASE }} className="px-1 py-8 text-center">
<div className="mb-2 text-[15px] font-semibold text-foreground">{title}</div> <div className="mb-2 text-[15px] font-semibold text-foreground">{title}</div>
{detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-muted-foreground">{detail}</div> : null} {detail ? <div className="mx-auto max-w-[48ch] text-[13px] leading-relaxed text-muted-foreground">{detail}</div> : null}
{action ? <div className="mt-5">{action}</div> : null} {action ? <div className="mt-5">{action}</div> : null}
</div> </motion.div>
); );
} }
+37 -40
View File
@@ -20,6 +20,16 @@
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground); --color-destructive-foreground: var(--destructive-foreground);
--color-destructive-muted: var(--destructive-muted);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-muted: var(--success-muted);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-muted: var(--warning-muted);
--color-caution: var(--caution);
--color-caution-foreground: var(--caution-foreground);
--color-caution-muted: var(--caution-muted);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@@ -63,6 +73,16 @@
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.577 0.245 27.325);
--destructive-muted: oklch(0.95 0.03 25);
--success: oklch(0.55 0.17 145);
--success-foreground: oklch(0.985 0 0);
--success-muted: oklch(0.96 0.04 155);
--warning: oklch(0.68 0.17 85);
--warning-foreground: oklch(0.985 0 0);
--warning-muted: oklch(0.97 0.04 95);
--caution: oklch(0.63 0.19 55);
--caution-foreground: oklch(0.985 0 0);
--caution-muted: oklch(0.96 0.04 60);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@@ -98,6 +118,16 @@
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.637 0.237 25.331); --destructive: oklch(0.637 0.237 25.331);
--destructive-foreground: oklch(0.985 0 0); --destructive-foreground: oklch(0.985 0 0);
--destructive-muted: oklch(0.25 0.06 25 / 0.5);
--success: oklch(0.72 0.17 155);
--success-foreground: oklch(0.985 0 0);
--success-muted: oklch(0.28 0.07 155 / 0.5);
--warning: oklch(0.82 0.17 90);
--warning-foreground: oklch(0.985 0 0);
--warning-muted: oklch(0.35 0.07 85 / 0.5);
--caution: oklch(0.75 0.17 60);
--caution-foreground: oklch(0.985 0 0);
--caution-muted: oklch(0.30 0.07 60 / 0.5);
--border: oklch(0.269 0 0); --border: oklch(0.269 0 0);
--input: oklch(0.269 0 0); --input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0); --ring: oklch(0.439 0 0);
@@ -116,6 +146,12 @@
--sidebar-ring: oklch(0.439 0 0); --sidebar-ring: oklch(0.439 0 0);
} }
/* Hide all scrollbars globally while keeping scroll functionality */
*::-webkit-scrollbar {
width: 0;
height: 0;
}
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
@@ -161,50 +197,11 @@
} }
} }
/* Dark mode scrollbars */ /* Dark mode color scheme */
.dark { .dark {
color-scheme: dark; color-scheme: dark;
} }
.dark *::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark *::-webkit-scrollbar-track {
background: oklch(0.205 0 0);
}
.dark *::-webkit-scrollbar-thumb {
background: oklch(0.4 0 0);
border-radius: 4px;
}
.dark *::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0);
}
/* Auto-hide scrollbar: always reserves space, thumb visible only on hover */
.scrollbar-auto-hide::-webkit-scrollbar {
width: 8px !important;
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-track {
background: transparent !important;
}
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
background: transparent !important;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
background: oklch(0.205 0 0) !important;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
background: oklch(0.4 0 0) !important;
}
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0) !important;
}
/* Expandable dialog transition for max-width changes */ /* Expandable dialog transition for max-width changes */
[data-slot="dialog-content"] { [data-slot="dialog-content"] {
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1); transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
+9
View File
@@ -95,6 +95,8 @@ export const IPC_CHANNELS = {
getAgent: "agent-company:get-agent", getAgent: "agent-company:get-agent",
getRun: "agent-company:get-run", getRun: "agent-company:get-run",
getApproval: "agent-company:get-approval", getApproval: "agent-company:get-approval",
getConnectorDisabledModels: "agent-company:get-connector-disabled-models",
setConnectorDisabledModels: "agent-company:set-connector-disabled-models",
} as const; } as const;
const optionalId = z.preprocess( const optionalId = z.preprocess(
@@ -119,6 +121,11 @@ export const connectorInputSchema = z.object({
notes: z.string().default(""), notes: z.string().default(""),
}); });
export const connectorDisabledModelsSchema = z.object({
connectorId: connectorInputSchema.shape.id,
disabledModels: z.array(z.object({ id: z.string(), label: z.string() })),
});
export const workspaceInputSchema = z.object({ export const workspaceInputSchema = z.object({
id: optionalId, id: optionalId,
companyId: z.string().uuid(), companyId: z.string().uuid(),
@@ -697,6 +704,8 @@ export type DesktopApi = {
deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>; deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>; getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>;
listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>; listConnectorModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
getConnectorDisabledModels: (connectorId: string) => Promise<DesktopResult<Array<{ id: string; label: string }>>>;
setConnectorDisabledModels: (input: z.infer<typeof connectorDisabledModelsSchema>) => Promise<DesktopResult<boolean>>;
openConnectorAuthTerminal: (connectorId: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>; openConnectorAuthTerminal: (connectorId: z.infer<typeof connectorInputSchema.shape.id>) => Promise<DesktopResult<boolean>>;
addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>; addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>;
listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>; listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>;