update
This commit is contained in:
1 parent
65e85135c1
commit
6f1de8047e
64 files changed
+1269
-515
No files matched your search
@@ -11,6 +11,7 @@ win-unpacked
|
||||
.blockmap
|
||||
.claude
|
||||
build
|
||||
.omc
|
||||
|
||||
# Test artifacts
|
||||
test-results/
|
||||
|
||||
@@ -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:
|
||||
firstOrDefaultFilePatterns:
|
||||
- '!**/node_modules/**'
|
||||
@@ -42,5 +20,27 @@ arm64:
|
||||
- package.json
|
||||
- assets/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:
|
||||
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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agent-company",
|
||||
"version": "0.4.133",
|
||||
"version": "0.4.135",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cross-platform Electron desktop control plane for local-first AI agent companies.",
|
||||
|
||||
+20
-1
@@ -1955,6 +1955,24 @@ export class AppDatabase {
|
||||
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[] {
|
||||
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')",
|
||||
@@ -2351,7 +2369,8 @@ export class AppDatabase {
|
||||
|
||||
private seedConnectors() {
|
||||
// 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>>(
|
||||
"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",
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import {
|
||||
IPC_CHANNELS,
|
||||
companyInputSchema,
|
||||
connectorDisabledModelsSchema,
|
||||
connectorInputSchema,
|
||||
deleteEntitySchema,
|
||||
openTerminalCommandSchema,
|
||||
@@ -98,6 +99,13 @@ export function registerCoreAdminHandlers({
|
||||
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) => {
|
||||
const parsed = secretInputSchema.parse(payload);
|
||||
const companyError = ensureCompanyExists(parsed.companyId);
|
||||
|
||||
@@ -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) => {
|
||||
const companyId = z.string().uuid().parse(payload);
|
||||
const err = ensureCompanyExists(companyId);
|
||||
|
||||
@@ -148,15 +148,19 @@ export function createMainRuntimeSupport(state: MainRuntimeState) {
|
||||
getApiPort: () => state.apiServer?.getPort() ?? 0,
|
||||
notify: (options) => requireIpcSupport().notify(options),
|
||||
listConnectorModels: async (connectorId) => {
|
||||
const disabled = new Set(requireDb().getDisabledModels(connectorId).map((m) => m.id));
|
||||
// For claude_compatible, return user-configured models from the DB
|
||||
// (stored as comma-separated values in the connector's model field)
|
||||
if (connectorId.startsWith("claude_compat")) {
|
||||
const connector = requireDb().getConnector(connectorId);
|
||||
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]);
|
||||
return definition.listModels ? definition.listModels() : [];
|
||||
const models = definition.listModels ? await definition.listModels() : [];
|
||||
return models.filter((m) => !disabled.has(m.id));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const api: DesktopApi = {
|
||||
deleteSecret: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteSecret, input),
|
||||
getSidebarBadges: (companyId) => ipcRenderer.invoke(IPC_CHANNELS.getSidebarBadges, companyId),
|
||||
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),
|
||||
addComment: (input) => ipcRenderer.invoke(IPC_CHANNELS.addComment, input),
|
||||
listComments: (input) => ipcRenderer.invoke(IPC_CHANNELS.listComments, input),
|
||||
|
||||
+32
-18
@@ -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 {
|
||||
CaretUpDown,
|
||||
@@ -519,7 +519,7 @@ export function App() {
|
||||
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 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 filteredNavItems = paletteQuery
|
||||
? navItems.filter((item) => item.label.toLowerCase().includes(paletteQuery) || item.id.toLowerCase().includes(paletteQuery))
|
||||
@@ -581,6 +581,19 @@ export function App() {
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!commandPaletteOpen) return;
|
||||
function onKey(event: KeyboardEvent) {
|
||||
@@ -741,13 +754,13 @@ export function App() {
|
||||
<TranslationContext.Provider value={t}>
|
||||
<div className="flex h-[100dvh] flex-col overflow-hidden bg-background">
|
||||
{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">
|
||||
{sidebarCollapsed ? (
|
||||
<div className={`sidebar-drag flex items-center justify-center px-2 pb-3 ${isMac ? "pt-[38px]" : "pt-3"}`}>
|
||||
<button
|
||||
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"
|
||||
onClick={() => setCommandPaletteOpen(true)}
|
||||
>
|
||||
@@ -830,27 +843,28 @@ export function App() {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Open command palette"
|
||||
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"
|
||||
aria-label={`Open command palette (${isMac ? "⌘" : "Ctrl+"}K)`}
|
||||
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)}
|
||||
>
|
||||
<Command size={14} />
|
||||
<kbd className="text-[10px] font-medium text-muted-foreground">{isMac ? "⌘K" : "^K"}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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">
|
||||
{sidebarNav.map((item) => (
|
||||
<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}
|
||||
/>
|
||||
{sidebarNav.slice(0, 3).map((item) => (
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
<div className={`my-2 border-t border-border ${sidebarCollapsed ? "mx-1" : "mx-2"}`} />
|
||||
{/* Collaborate & Extend: Communication, Skills, Documents, Approvals, Social */}
|
||||
<div className="space-y-0.5">
|
||||
{sidebarNav.slice(3).map((item) => (
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
</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"}>
|
||||
|
||||
{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} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import type { AppLocale } from "@shared/locale";
|
||||
import { useFocusTrap } from "../ui";
|
||||
|
||||
interface ClaudeSettingsDialogProps {
|
||||
claudeApiKey: string;
|
||||
@@ -26,17 +28,20 @@ export function ClaudeSettingsDialog({
|
||||
saving,
|
||||
currentLocale,
|
||||
}: 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 (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
id="claude-api-key"
|
||||
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"
|
||||
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>
|
||||
<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
|
||||
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"
|
||||
value={claudeBaseUrl}
|
||||
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>
|
||||
<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
|
||||
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"
|
||||
value={claudeModel}
|
||||
onChange={(e) => setClaudeModel(e.target.value)}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function CompanyStep({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -39,11 +39,11 @@ export function ConnectorReadiness({
|
||||
{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)]" />
|
||||
) : 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 ? (
|
||||
<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="flex items-center gap-2">
|
||||
@@ -75,7 +75,7 @@ export function ConnectorReadiness({
|
||||
})}
|
||||
</div>
|
||||
{!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")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function LaunchStep({
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-[28px] tracking-[-0.03em] text-foreground">
|
||||
@@ -72,7 +72,7 @@ export function LaunchStep({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function MissionStep({
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function NodeCheckStep({
|
||||
{checking ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-primary" />
|
||||
) : 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 ? (
|
||||
<XCircle size={18} weight="fill" className="shrink-0 text-destructive" />
|
||||
) : null}
|
||||
@@ -88,7 +88,7 @@ export function NodeCheckStep({
|
||||
{checking ? (
|
||||
<span className="text-muted-foreground">{t("onboarding.providerChecking")}...</span>
|
||||
) : 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 ?? "")}
|
||||
</span>
|
||||
) : nodeStatus ? (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function OnboardingHero({
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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 */}
|
||||
<motion.div
|
||||
@@ -120,7 +120,7 @@ export function OnboardingHero({
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium transition-colors duration-300 ${
|
||||
index < stepIndex
|
||||
? "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400"
|
||||
? "bg-success-muted text-success"
|
||||
: index === stepIndex
|
||||
? "bg-accent text-primary"
|
||||
: "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 }) {
|
||||
if (status === "ready") {
|
||||
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="h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-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-success" />
|
||||
{t("onboarding.providerReady")}
|
||||
</span>
|
||||
);
|
||||
@@ -77,8 +77,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
|
||||
}
|
||||
if (status === "auth_required") {
|
||||
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="h-1.5 w-1.5 rounded-full bg-yellow-600 dark:bg-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-warning" />
|
||||
{t("onboarding.providerAuthRequired")}
|
||||
</span>
|
||||
);
|
||||
@@ -92,8 +92,8 @@ function StatusBadge({ status, t }: { status: ConnectorStatus; t: (key: string)
|
||||
);
|
||||
}
|
||||
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="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-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-caution" />
|
||||
{status.replaceAll("_", " ")}
|
||||
</span>
|
||||
);
|
||||
@@ -611,7 +611,7 @@ export function ProvidersStep({
|
||||
|
||||
{/* Error message */}
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useReducedMotion } from "./ui"
|
||||
const TONE_CONFIG = {
|
||||
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)" },
|
||||
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
|
||||
|
||||
export default function ToastContainer() {
|
||||
|
||||
@@ -15,8 +15,8 @@ function DeleteConfirmDialog({ open, entityName, onConfirm, onCancel }: { open:
|
||||
if (!open) return null
|
||||
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={{ 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()}>
|
||||
<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>
|
||||
<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-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>
|
||||
<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>
|
||||
@@ -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])
|
||||
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 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 (
|
||||
<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>
|
||||
{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>
|
||||
{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}
|
||||
@@ -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])
|
||||
return (
|
||||
<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>
|
||||
{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>}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RuleCard({ rule, onToggle, onDelete, onEdit }: { rule: Automatio
|
||||
</button>
|
||||
<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={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>
|
||||
</motion.div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Gear, Lightning, X } from "@phosphor-icons/react"
|
||||
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"
|
||||
|
||||
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)
|
||||
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])
|
||||
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
|
||||
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]
|
||||
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={{ 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 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>
|
||||
</div>
|
||||
<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">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>
|
||||
@@ -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 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><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 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>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowsClockwise, Gear, X } from "@phosphor-icons/react"
|
||||
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"
|
||||
|
||||
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)
|
||||
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])
|
||||
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
|
||||
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" }]
|
||||
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={{ 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="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">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>
|
||||
|
||||
@@ -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="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="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>
|
||||
)
|
||||
@@ -44,9 +44,9 @@ function WorkflowCard({ workflow, isRunning, onDelete, onEdit, onRun }: { workfl
|
||||
</button>
|
||||
<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>
|
||||
<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={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>
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useT } from "../../i18n"
|
||||
|
||||
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
|
||||
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" />}
|
||||
</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">
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)}
|
||||
</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 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" />
|
||||
</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">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
|
||||
|
||||
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" },
|
||||
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)
|
||||
return parts.map((part, i) =>
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function ConnectorEditor({ connector, onSaved }: { connector: ConnectorRe
|
||||
<div className="grid gap-4">
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
{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="mt-1.5 leading-relaxed">{authActionCopy.message}</div>
|
||||
<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 (
|
||||
<div className="space-y-0.5">
|
||||
{recentActivity.map((item, index) => {
|
||||
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));
|
||||
|
||||
return (
|
||||
|
||||
@@ -79,8 +79,8 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
|
||||
{incidents.map((incident) => {
|
||||
const isHardStop = incident.thresholdType === "hard_stop";
|
||||
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 textColor = isHardStop ? "text-destructive" : "text-yellow-600 dark:text-yellow-400";
|
||||
const bgColor = isHardStop ? "bg-destructive-muted" : "bg-warning-muted";
|
||||
const textColor = isHardStop ? "text-destructive" : "text-warning";
|
||||
const pct = incident.amountLimit > 0 ? Math.round((incident.amountObserved / incident.amountLimit) * 100) : 0;
|
||||
|
||||
return (
|
||||
@@ -132,14 +132,14 @@ export function BudgetAlertBanner({ agents, onNavigate, companyId }: BudgetAlert
|
||||
<div
|
||||
role="alert"
|
||||
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="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 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
|
||||
</div>
|
||||
<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)] ${
|
||||
agent.pct >= 100
|
||||
? "text-destructive"
|
||||
: "text-yellow-600 dark:text-yellow-400"
|
||||
: "text-warning"
|
||||
}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
|
||||
@@ -84,7 +84,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
<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">
|
||||
<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
|
||||
</span>
|
||||
<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} />
|
||||
|
||||
{/* 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
|
||||
label={t("dash.activeTasks")}
|
||||
value={activeTasks.length}
|
||||
@@ -142,8 +142,8 @@ export function Dashboard(props: DashboardProps) {
|
||||
{/* Needs Attention — moved to top, only shows when items exist */}
|
||||
{hasAttentionItems ? (
|
||||
<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">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-600 dark:bg-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-warning" />
|
||||
{t("dash.needsAttentionSection")}
|
||||
</div>
|
||||
<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] }}
|
||||
type="button"
|
||||
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="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">
|
||||
@@ -171,14 +171,17 @@ export function Dashboard(props: DashboardProps) {
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
{failedRuns.slice(0, 3).map((run) => {
|
||||
{failedRuns.slice(0, 3).map((run, i) => {
|
||||
const agent = agentMap.get(run.agentId);
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
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"
|
||||
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" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -188,19 +191,22 @@ export function Dashboard(props: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<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);
|
||||
return (
|
||||
<button
|
||||
<motion.button
|
||||
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"
|
||||
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="truncate text-[13px] font-medium text-foreground">{msg.subject}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
@@ -208,7 +214,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</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"
|
||||
>
|
||||
<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" ? (
|
||||
<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="relative inline-flex h-1.5 w-1.5 rounded-full bg-green-600 dark:bg-green-400" />
|
||||
<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-success" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@ interface KanbanProps {
|
||||
|
||||
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: "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_review", label: "In Review", tone: "text-yellow-600 dark:text-yellow-400", dotColor: "bg-yellow-600 dark:bg-yellow-400" },
|
||||
{ status: "done", label: "Done", tone: "text-green-600 dark:text-green-400", dotColor: "bg-green-600 dark:bg-green-400" },
|
||||
{ status: "in_review", label: "In Review", tone: "text-warning", dotColor: "bg-warning" },
|
||||
{ status: "done", label: "Done", tone: "text-success", dotColor: "bg-success" },
|
||||
{ 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) => {
|
||||
const colTasks = tasksByStatus[col.status] ?? [];
|
||||
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">
|
||||
<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>
|
||||
@@ -88,14 +88,14 @@ export function KanbanBoard({ tasks, agents, runs, onSelectTask }: KanbanProps)
|
||||
<span className="text-[10px] italic text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
{isRunning ? (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap text-[10px] text-green-600 dark:text-green-400">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
|
||||
<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-success" aria-hidden="true" />
|
||||
Running
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -17,23 +17,23 @@ export function MetricCard({
|
||||
}) {
|
||||
const toneMap = {
|
||||
default: "text-foreground",
|
||||
success: "text-green-600 dark:text-green-400",
|
||||
warn: "text-yellow-600 dark:text-yellow-400",
|
||||
success: "text-success",
|
||||
warn: "text-warning",
|
||||
danger: "text-destructive",
|
||||
accent: "text-primary",
|
||||
};
|
||||
const bgMap = {
|
||||
default: "bg-muted",
|
||||
success: "bg-green-100 dark:bg-green-900/50",
|
||||
warn: "bg-yellow-100 dark:bg-yellow-900/50",
|
||||
danger: "bg-red-100 dark:bg-red-900/50",
|
||||
success: "bg-success-muted",
|
||||
warn: "bg-warning-muted",
|
||||
danger: "bg-destructive-muted",
|
||||
accent: "bg-accent",
|
||||
};
|
||||
|
||||
const borderMap = {
|
||||
default: "border-l-transparent",
|
||||
success: "border-l-[color:var(--primary)]",
|
||||
warn: "border-l-[color:var(--destructive)]",
|
||||
success: "border-l-[color:var(--success)]",
|
||||
warn: "border-l-[color:var(--warning)]",
|
||||
danger: "border-l-[color:var(--destructive)]",
|
||||
accent: "border-l-[color:var(--primary)]",
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export function MetricCard({
|
||||
<button
|
||||
type="button"
|
||||
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]}`}>
|
||||
<Icon size={24} weight="fill" className={toneMap[tone]} />
|
||||
|
||||
@@ -41,9 +41,9 @@ export function RecoveryBanner() {
|
||||
const handleDismissAll = () => setDismissed(true);
|
||||
|
||||
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 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" />
|
||||
<span>{runs.length} interrupted task(s) detected from previous session</span>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ export function RecoveryBanner() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -17,8 +17,8 @@ interface BudgetRow {
|
||||
|
||||
function getBarColor(pct: number): string {
|
||||
if (pct >= 100) return "bg-destructive";
|
||||
if (pct >= 80) return "bg-yellow-600 dark:bg-yellow-400";
|
||||
return "bg-green-600 dark:bg-green-400";
|
||||
if (pct >= 80) return "bg-warning";
|
||||
return "bg-success";
|
||||
}
|
||||
|
||||
function scopeLabel(scopeType: string): string {
|
||||
@@ -119,7 +119,7 @@ export function BudgetUtilization({ companyId, agents }: BudgetUtilizationProps)
|
||||
/>
|
||||
{/* 80% warning marker */}
|
||||
<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%" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ActivityList({ activity }: { activity: ActivityRecord[] }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<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>
|
||||
<Select value={entityFilter} onChange={setEntityFilter} options={ENTITY_TYPE_OPTIONS.map((v) => ({ value: v, label: v === "all" ? t("exec.allTypes") : v }))} />
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useAppStore } from "../../state/store"
|
||||
|
||||
function getTypeBadgeClass(type: string): string {
|
||||
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 === "secret_access") return "bg-red-100 dark:bg-red-900/50 text-destructive"
|
||||
if (type === "approve_ceo_strategy") return "bg-warning-muted text-warning"
|
||||
if (type === "secret_access") return "bg-destructive-muted text-destructive"
|
||||
if (type === "social_post") return "bg-accent text-primary"
|
||||
if (type === "deliverable_review") return "bg-accent text-primary"
|
||||
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>
|
||||
{mgr ? <div><span className="text-muted-foreground">Reports to:</span> <span className="text-foreground">{mgr.name}</span></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}
|
||||
{isSocialPost && approval.payloadJson ? (
|
||||
<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" : ""}`}
|
||||
>
|
||||
<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)}
|
||||
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={`truncate text-[13px] text-foreground ${selectedApprovalId === approval.id ? "font-semibold" : "font-medium"}`}>{approval.payloadSummary}</div>
|
||||
<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 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 nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
||||
|
||||
const scopeLabel = status.scopeType === "company"
|
||||
? "Company"
|
||||
@@ -62,11 +62,11 @@ export function BudgetSummary({ agents, costs, companyId }: BudgetSummaryProps)
|
||||
{status.scopeType}
|
||||
</span>
|
||||
<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 className="flex items-center gap-2">
|
||||
<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 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) => {
|
||||
const pct = agent.budgetMonthlyUsd > 0 ? (agent.spentMonthlyUsd / agent.budgetMonthlyUsd) * 100 : 0
|
||||
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 nameColor = isDanger ? "text-destructive" : isWarning ? "text-yellow-600 dark:text-yellow-400" : "text-foreground"
|
||||
const barColor = isDanger ? "bg-destructive" : isWarning ? "bg-warning" : "bg-success"
|
||||
const nameColor = isDanger ? "text-destructive" : isWarning ? "text-warning" : "text-foreground"
|
||||
return (
|
||||
<ListRow key={agent.id}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-[14px] font-medium ${nameColor}`}>{agent.name}</div>
|
||||
{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 className="flex items-center gap-2">
|
||||
<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 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 className="shrink-0 text-right">
|
||||
<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>
|
||||
|
||||
@@ -11,8 +11,8 @@ interface IncidentListProps {
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
open: "text-destructive bg-red-100 dark:bg-red-900/50",
|
||||
resolved: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
|
||||
open: "text-destructive bg-destructive-muted",
|
||||
resolved: "text-success bg-success-muted",
|
||||
auto_resolved_limit_increase: "text-primary bg-accent",
|
||||
auto_closed: "text-muted-foreground bg-muted",
|
||||
};
|
||||
@@ -93,8 +93,8 @@ export function IncidentList({ companyId, agents, projects }: IncidentListProps)
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.1em] ${
|
||||
isWarn
|
||||
? "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50"
|
||||
: "text-destructive bg-red-100 dark:bg-red-900/50"
|
||||
? "text-warning bg-warning-muted"
|
||||
: "text-destructive bg-destructive-muted"
|
||||
}`}
|
||||
>
|
||||
{isWarn ? <Warning size={10} weight="bold" /> : null}
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
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 { AppLocale } from "@shared/locale";
|
||||
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 {
|
||||
connectors: ProfileSnapshot["connectors"];
|
||||
@@ -11,194 +54,738 @@ interface ModelManagementProps {
|
||||
onSaved: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Check if a claude_compat connector has been configured (has env bindings). */
|
||||
function isConfigured(c: ConnectorRecord): boolean {
|
||||
return c.envBindingText.includes("ANTHROPIC_BASE_URL") || c.envBindingText.includes("ANTHROPIC_API_KEY");
|
||||
interface ModelEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
function getDisplayName(connector: ConnectorRecord): string {
|
||||
if (connector.id.startsWith("claude_compat") && connector.notes) return connector.notes;
|
||||
return connector.label;
|
||||
type ConnectorStatus = ProfileSnapshot["connectors"][number]["status"];
|
||||
|
||||
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,
|
||||
locale,
|
||||
zh,
|
||||
onSaved,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
connector: ConnectorRecord;
|
||||
locale: AppLocale;
|
||||
zh: boolean;
|
||||
onSaved: () => Promise<void>;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [models, setModels] = useState<string[]>(() => {
|
||||
const raw = connector.model ?? "";
|
||||
return raw.split(",").map((m) => m.trim()).filter(Boolean);
|
||||
});
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [models, setModels] = useState<ModelEntry[]>([]);
|
||||
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(() => {
|
||||
const raw = connector.model ?? "";
|
||||
setModels(raw.split(",").map((m) => m.trim()).filter(Boolean));
|
||||
setDirty(false);
|
||||
}, [connector.model]);
|
||||
setApiKey(parseEnvValue(connector.envBindingText, envKeyName));
|
||||
}, [connector.envBindingText, envKeyName]);
|
||||
|
||||
function addModel() {
|
||||
const trimmed = newModel.trim();
|
||||
if (!trimmed || models.includes(trimmed)) return;
|
||||
setModels((prev) => [...prev, trimmed]);
|
||||
setNewModel("");
|
||||
setDirty(true);
|
||||
}
|
||||
const loadModels = useCallback(async () => {
|
||||
try {
|
||||
const [enabledResult, disabledResult] = await Promise.all([
|
||||
window.agentCompany.listConnectorModels(connector.id),
|
||||
window.agentCompany.getConnectorDisabledModels(connector.id),
|
||||
]);
|
||||
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) {
|
||||
setModels((prev) => prev.filter((x) => x !== m));
|
||||
setDirty(true);
|
||||
useEffect(() => {
|
||||
void loadModels();
|
||||
}, [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() {
|
||||
setSaving(true);
|
||||
try {
|
||||
unwrap(await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: models.join(",") || null,
|
||||
envBindingText: connector.envBindingText,
|
||||
notes: connector.notes,
|
||||
}));
|
||||
setDirty(false);
|
||||
const lines: string[] = [];
|
||||
if (apiKey.trim()) lines.push(`${envKeyName}=${apiKey.trim()}`);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
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();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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 () => {
|
||||
async function handleClear() {
|
||||
if (!connector) return;
|
||||
try {
|
||||
const result = await window.agentCompany.listConnectorModels(connector.id);
|
||||
if (result.ok && result.data.length > 0) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}, [connector.id]);
|
||||
|
||||
useEffect(() => { void loadModels(); }, [loadModels]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-[13px] font-semibold text-foreground">{getDisplayName(connector)}</div>
|
||||
{models.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{models.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="inline-block rounded-md border border-border bg-muted px-2.5 py-1 text-[12px] text-foreground"
|
||||
>
|
||||
{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>
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.saveConnector({
|
||||
id: connector.id,
|
||||
command: connector.command,
|
||||
model: null,
|
||||
envBindingText: "",
|
||||
notes: "",
|
||||
}),
|
||||
);
|
||||
unwrap(
|
||||
await window.agentCompany.setConnectorDisabledModels({
|
||||
connectorId: connector.id,
|
||||
disabledModels: [],
|
||||
}),
|
||||
);
|
||||
await onSaved();
|
||||
onCleared?.();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
{visibleConnectors.map((connector) =>
|
||||
connector.id.startsWith("claude_compat") ? (
|
||||
<CompatModelEditor key={connector.id} connector={connector} locale={locale} onSaved={onSaved} />
|
||||
) : (
|
||||
<NativeModelList key={connector.id} connector={connector} locale={locale} />
|
||||
),
|
||||
)}
|
||||
<ProviderCard
|
||||
title={displayTitle}
|
||||
badge={<StatusBadge status={status} zh={zh} />}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
|
||||
{policies.map((policy) => {
|
||||
const util = getUtilization(policy.id);
|
||||
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 (
|
||||
<ListRow key={policy.id} onClick={() => handleEdit(policy)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -110,7 +110,7 @@ export function PolicyList({ companyId, agents, projects }: PolicyListProps) {
|
||||
</span>
|
||||
<span className="truncate text-[13px] font-medium text-foreground">{getScopeName(policy)}</span>
|
||||
{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 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" }}>
|
||||
|
||||
@@ -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.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 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.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>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function SocialDraftPreview({ payloadJson, platform, isPending: _isPendin
|
||||
) : null}
|
||||
|
||||
{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} />
|
||||
Scheduled: {formatScheduledDate(draft.scheduledAt)}
|
||||
</div>
|
||||
|
||||
@@ -52,7 +52,7 @@ export function TaskComments({ taskId, companyId, agents, comments, onRefresh }:
|
||||
)}
|
||||
{submitError ? <InlineNotice message={submitError} /> : null}
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ListChecks } from "@phosphor-icons/react"
|
||||
import type { GoalRecord, ProfileSnapshot, ProjectRecord, WorkspaceRecord, TaskRecord } from "@shared/types"
|
||||
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) }
|
||||
}
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
return (
|
||||
<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")} />
|
||||
<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>
|
||||
<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 }))]} />
|
||||
<div className="md:col-span-2">
|
||||
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
|
||||
<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>
|
||||
{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}
|
||||
<div className="md:col-span-2"><ActionButton label={t("exec.createTask")} tone="accent" onClick={() => void handleCreateTask()} icon={ListChecks} loading={submitting} /></div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
return (
|
||||
<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) }} />
|
||||
{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 && actionNotice ? <InlineNotice message={actionNotice} tone="success" /> : null}
|
||||
{editing ? (
|
||||
@@ -65,7 +65,7 @@ export function TaskInspector({ task, tasks, runs, agents, goals, projects, work
|
||||
</div>
|
||||
{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>
|
||||
: 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">
|
||||
{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" />}
|
||||
@@ -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 (
|
||||
<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="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="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-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 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>
|
||||
{subAssignee ? <span className="shrink-0 text-[10px] text-muted-foreground">{subAssignee.name}</span> : null}<StatusPill status={sub.status} />
|
||||
</div>) })}</div></div>) })()}
|
||||
<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 (
|
||||
<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 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>
|
||||
{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}
|
||||
<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-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-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>
|
||||
)
|
||||
|
||||
@@ -27,11 +27,11 @@ export const ENTITY_TYPE_OPTIONS = [
|
||||
|
||||
export function WorkspaceStatePill({ state }: { state: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
ready: "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/50",
|
||||
busy: "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/50",
|
||||
blocked: "text-destructive bg-red-100 dark:bg-red-900/50",
|
||||
ready: "text-success bg-success-muted",
|
||||
busy: "text-warning bg-warning-muted",
|
||||
blocked: "text-destructive bg-destructive-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 (
|
||||
<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 }) {
|
||||
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`} />
|
||||
}
|
||||
@@ -126,8 +126,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
|
||||
const ag = candidate.agent;
|
||||
return (
|
||||
<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">
|
||||
<UserPlus size={16} className="text-yellow-600 dark:text-yellow-400" />
|
||||
<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-warning" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -247,7 +247,7 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
|
||||
</div>
|
||||
<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-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 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>
|
||||
@@ -278,8 +278,8 @@ export function HiringPipeline({ agents, approvals, onRefresh, onNavigateToAgent
|
||||
<span className="text-[10px] text-muted-foreground">{t("hiring.active")}</span>
|
||||
{pending > 0 && (
|
||||
<>
|
||||
<span className="text-sm font-bold text-yellow-600 dark:text-yellow-400">+{pending}</span>
|
||||
<span className="text-[10px] text-yellow-600 dark:text-yellow-400">{t("hiring.pendingApproval")}</span>
|
||||
<span className="text-sm font-bold text-warning">+{pending}</span>
|
||||
<span className="text-[10px] text-warning">{t("hiring.pendingApproval")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,12 @@ export function getHiringStage(agent: AgentRecord, approval: ApprovalRecord | nu
|
||||
|
||||
export function stageColor(stage: HiringStage): string {
|
||||
switch (stage) {
|
||||
case "requisition": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400";
|
||||
case "pending_approval": return "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400";
|
||||
case "approved": return "bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400";
|
||||
case "requisition": return "bg-warning-muted text-warning";
|
||||
case "pending_approval": return "bg-warning-muted text-warning";
|
||||
case "approved": return "bg-success-muted text-success";
|
||||
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 "rejected": return "bg-red-100 dark:bg-red-900/50 text-destructive";
|
||||
case "active": return "bg-success-muted text-success";
|
||||
case "rejected": return "bg-destructive-muted text-destructive";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function PerformanceView({ metrics, onNavigate }: PerformanceProps) {
|
||||
</div>
|
||||
<div className="flex gap-4 text-[12px]">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<span className={`text-[12px] font-semibold ${
|
||||
am.successRate >= 80 ? "text-green-600 dark:text-green-400"
|
||||
: am.successRate >= 50 ? "text-yellow-600 dark:text-yellow-400"
|
||||
am.successRate >= 80 ? "text-success"
|
||||
: am.successRate >= 50 ? "text-warning"
|
||||
: "text-destructive"
|
||||
}`}>
|
||||
{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="mt-3 flex gap-4">
|
||||
<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>
|
||||
<div className="h-10 w-px bg-[color:var(--border)]" />
|
||||
@@ -53,7 +53,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
||||
</div>
|
||||
<div className="h-10 w-px bg-[color:var(--border)]" />
|
||||
<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>
|
||||
<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="space-y-1.5">
|
||||
{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" />
|
||||
{h}
|
||||
</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="space-y-1.5">
|
||||
{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" />
|
||||
{b}
|
||||
</div>
|
||||
@@ -106,16 +106,16 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
||||
>
|
||||
<div className="relative">
|
||||
<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 === "error" ? "bg-red-100 dark:bg-red-900/50 text-destructive"
|
||||
ar.status === "running" ? "bg-success-muted text-success"
|
||||
: ar.status === "error" ? "bg-destructive-muted text-destructive"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{getAgentInitials(ar.agentName)}
|
||||
</div>
|
||||
{ar.status === "running" ? (
|
||||
<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="relative inline-flex h-2 w-2 rounded-full bg-green-600 dark:bg-green-400" />
|
||||
<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-success" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@ export function StandupView({ report, onNavigate }: StandupProps) {
|
||||
</div>
|
||||
<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.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.costLast24h > 0 ? <span className="tabular-nums">${ar.costLast24h.toFixed(2)}</span> : null}
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,8 @@ export function CompactMetric({ label, value, tone = "default", subtitle }: {
|
||||
}) {
|
||||
const toneMap = {
|
||||
default: "text-foreground",
|
||||
success: "text-green-600 dark:text-green-400",
|
||||
warn: "text-yellow-600 dark:text-yellow-400",
|
||||
success: "text-success",
|
||||
warn: "text-warning",
|
||||
danger: "text-destructive",
|
||||
accent: "text-primary",
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export function RunProgressBar({ succeeded, failed, total }: { succeeded: number
|
||||
return (
|
||||
<div className="flex h-3 overflow-hidden rounded-full bg-muted">
|
||||
<motion.div
|
||||
className="h-full bg-green-600 dark:bg-green-400"
|
||||
className="h-full bg-success"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${successPct}%` }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Robot } from "@phosphor-icons/react";
|
||||
import type { AgentRecord, ConnectorRecord, WorkspaceRecord } from "@shared/types";
|
||||
import { isConnectorExecutionReady } from "@shared/connector-policy";
|
||||
@@ -65,11 +66,13 @@ export function AgentCreator({
|
||||
}
|
||||
}, [executionReadyConnectors, connectorId]);
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<Input label="Agent name" value={name} onChange={setName} placeholder="Agent name" />
|
||||
<Input label="Role" value={role} onChange={setRole} placeholder="e.g. Engineer, PM, CEO" />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" />
|
||||
<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" hint="Determines which tasks the agent can handle" />
|
||||
<Input label="Title" value={title} onChange={setTitle} placeholder="Title" hint="Job title shown under the name" />
|
||||
<Select
|
||||
label="Connector"
|
||||
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 }))}
|
||||
/>
|
||||
{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.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -102,31 +105,42 @@ export function AgentCreator({
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
label="Workspace"
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
options={[
|
||||
{ value: "", label: "No workspace" },
|
||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Reports to"
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
options={[
|
||||
{ value: "", label: "No manager" },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<Input label="Monthly budget (USD)" value={budget} onChange={setBudget} placeholder="120" type="number" />
|
||||
<TextArea
|
||||
label="Capabilities"
|
||||
value={capabilities}
|
||||
onChange={setCapabilities}
|
||||
placeholder="What this agent does"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)} className="flex items-center gap-1.5 text-[12px] font-medium text-muted-foreground transition hover:text-foreground">
|
||||
<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>
|
||||
Advanced settings
|
||||
</button>
|
||||
<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">
|
||||
<Select
|
||||
label="Workspace"
|
||||
value={workspaceId}
|
||||
onChange={setWorkspaceId}
|
||||
options={[
|
||||
{ value: "", label: "No workspace" },
|
||||
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name })),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Reports to"
|
||||
value={reportsTo}
|
||||
onChange={setReportsTo}
|
||||
options={[
|
||||
{ value: "", label: "No manager" },
|
||||
...agents.map((a) => ({ value: a.id, label: a.name })),
|
||||
]}
|
||||
/>
|
||||
<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">
|
||||
New agents enter the hiring pipeline first. Approval activates the agent and keeps onboarding, budgets, and org history linked to the same record.
|
||||
</p>
|
||||
|
||||
@@ -155,7 +155,7 @@ export function AgentEditor({
|
||||
options={connectorOptions}
|
||||
/>
|
||||
{selectedConnectorIssue ? (
|
||||
<p className="text-[12px] text-yellow-600 dark:text-yellow-400">
|
||||
<p className="text-[12px] text-warning">
|
||||
{selectedConnectorIssue}
|
||||
</p>
|
||||
) : 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 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) => (
|
||||
<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>
|
||||
<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 isCEO = node.children.length > 0 && !node.agent.reportsTo;
|
||||
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 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 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-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} />
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<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 ? (
|
||||
<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" }} />
|
||||
@@ -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>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function BudgetBar({ spent, budget }: { spent: number; budget: number })
|
||||
<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-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}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export function ConfirmDelete({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
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>
|
||||
<ActionButton label="Yes, delete" tone="danger" onClick={onConfirm} icon={Trash} />
|
||||
<ActionButton label="Cancel" onClick={onCancel} icon={XCircle} />
|
||||
@@ -118,7 +118,7 @@ export function HeartbeatSection({
|
||||
}
|
||||
/>
|
||||
{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.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function RunDetail({
|
||||
<DetailRow label="Cost">{formatMoney(selectedRun.costUsd)}</DetailRow>
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -118,7 +118,7 @@ export function RunDetail({
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
) : 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">
|
||||
<span className="truncate">@{account.accountName}</span>
|
||||
{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} />
|
||||
Approval required
|
||||
</span>
|
||||
@@ -58,16 +58,16 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
|
||||
<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 ${
|
||||
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"
|
||||
? "bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
|
||||
? "bg-warning-muted text-warning"
|
||||
: account.status === "suspended"
|
||||
? "bg-red-100 dark:bg-red-900/50 text-destructive"
|
||||
? "bg-destructive-muted text-destructive"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||
isLoggedIn ? "bg-green-600 dark:bg-green-400"
|
||||
: account.status === "login_required" ? "bg-yellow-600 dark:bg-yellow-400"
|
||||
isLoggedIn ? "bg-success"
|
||||
: account.status === "login_required" ? "bg-warning"
|
||||
: account.status === "suspended" ? "bg-destructive"
|
||||
: "bg-[color:var(--muted-foreground)]"
|
||||
}`} />
|
||||
@@ -89,7 +89,7 @@ export function AccountCard({ account, onLogin, onDelete, onClick }: AccountCard
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
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} />
|
||||
</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]"
|
||||
>
|
||||
<Icon size={16} className={
|
||||
isSuccess ? "text-green-600 dark:text-green-400"
|
||||
isSuccess ? "text-success"
|
||||
: isFailed ? "text-destructive"
|
||||
: isRunning ? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
@@ -94,7 +94,7 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
|
||||
<button
|
||||
type="button"
|
||||
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} />
|
||||
Cancel
|
||||
@@ -106,10 +106,10 @@ export function ActionHistory({ actions, agents, onOpenPath, onCancelAction, onN
|
||||
{timeAgo(action.createdAt)}
|
||||
</div>
|
||||
<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"
|
||||
: isFailed ? "bg-red-100 dark:bg-red-900/50 text-destructive"
|
||||
isSuccess ? "bg-success-muted text-success"
|
||||
: isFailed ? "bg-destructive-muted text-destructive"
|
||||
: 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"
|
||||
}`}>
|
||||
{action.status.replace("_", " ")}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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" }) {
|
||||
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 (
|
||||
<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}
|
||||
@@ -43,7 +46,7 @@ export function SectionButton({
|
||||
<Icon size={18} weight={active ? "fill" : "regular"} className={active ? "text-primary" : ""} />
|
||||
{badge ? (
|
||||
<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}
|
||||
</button>
|
||||
@@ -59,7 +62,7 @@ export function SectionButton({
|
||||
}`}
|
||||
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" : ""} />
|
||||
<span className={`text-[13px] tracking-[0.005em] ${active ? "font-bold" : "font-medium"}`}>{label}</span>
|
||||
{badge ? <Badge count={badge} tone={badgeTone} /> : null}
|
||||
@@ -120,32 +123,46 @@ export function Modal({
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onClose}>
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="flex w-full flex-col rounded-lg border border-border bg-card shadow-xl"
|
||||
style={{ maxWidth: width, maxHeight: "85vh" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3 shrink-0 cursor-pointer rounded-lg p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
||||
aria-label="Close"
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
initial={{ scale: 0.96, opacity: 0, y: 8 }}
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="min-w-0 truncate text-[16px] font-bold tracking-[-0.015em] text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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,
|
||||
disabled = false,
|
||||
label,
|
||||
hint,
|
||||
id,
|
||||
ariaLabel,
|
||||
}: {
|
||||
@@ -176,6 +194,7 @@ export function Input({
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
@@ -195,7 +214,10 @@ export function Input({
|
||||
if (label) {
|
||||
return (
|
||||
<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}
|
||||
</label>
|
||||
);
|
||||
@@ -209,6 +231,7 @@ export function TextArea({
|
||||
placeholder,
|
||||
rows = 4,
|
||||
label,
|
||||
hint,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
@@ -217,6 +240,7 @@ export function TextArea({
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
readOnly?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
@@ -235,7 +259,10 @@ export function TextArea({
|
||||
if (label) {
|
||||
return (
|
||||
<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}
|
||||
</label>
|
||||
);
|
||||
@@ -249,6 +276,7 @@ export function Select({
|
||||
options,
|
||||
compact = false,
|
||||
label,
|
||||
hint,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
@@ -256,6 +284,7 @@ export function Select({
|
||||
options: Array<{ value: string; label: string }>;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const select = (
|
||||
@@ -279,7 +308,10 @@ export function Select({
|
||||
if (label) {
|
||||
return (
|
||||
<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}
|
||||
</label>
|
||||
);
|
||||
@@ -312,7 +344,7 @@ export function ActionButton({
|
||||
|
||||
return (
|
||||
<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}
|
||||
disabled={isDisabled}
|
||||
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 checking = status === "detected";
|
||||
const toneClass = positive
|
||||
? "text-green-600 dark:text-green-400"
|
||||
? "text-success"
|
||||
: negative
|
||||
? "text-destructive"
|
||||
: warning
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
? "text-warning"
|
||||
: "text-muted-foreground";
|
||||
const dotClass = positive
|
||||
? "bg-green-600 dark:bg-green-400"
|
||||
? "bg-success"
|
||||
: negative
|
||||
? "bg-destructive"
|
||||
: warning
|
||||
? "bg-yellow-600 dark:bg-yellow-400"
|
||||
? "bg-warning"
|
||||
: "bg-[color:var(--muted-foreground)]";
|
||||
|
||||
const displayLabel = checking ? "checking" : status.replaceAll("_", " ");
|
||||
@@ -374,10 +406,10 @@ export function InlineNotice({
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
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"
|
||||
? "border-[color:var(--destructive)] bg-yellow-100 dark:bg-yellow-900/50 text-yellow-600 dark:text-yellow-400"
|
||||
: "border-[color:var(--destructive)] bg-red-100 dark:bg-red-900/50 text-destructive";
|
||||
? "border-[color:var(--warning)] bg-warning-muted text-warning"
|
||||
: "border-[color:var(--destructive)] bg-destructive-muted text-destructive";
|
||||
return (
|
||||
<div className={`rounded-lg border px-4 py-3 text-[13px] ${toneClass}`} role={tone === "danger" ? "alert" : "status"}>
|
||||
{action ? (
|
||||
@@ -409,26 +441,33 @@ export function ConfirmDialog({ open, title, message, errorMessage, onConfirm, o
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[color:var(--foreground)]/45" onClick={onCancel}>
|
||||
<div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
|
||||
<div className="text-[13px] text-muted-foreground mb-6">{message}</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>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<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}>
|
||||
<motion.div
|
||||
ref={trapRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
transition={{ duration: 0.18, ease: EASE }}
|
||||
className="w-full max-w-[400px] rounded-lg bg-card border border-border p-6 shadow-lg"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div id="confirm-dialog-title" className="text-[16px] font-semibold text-foreground mb-2">{title}</div>
|
||||
<div className="text-[13px] text-muted-foreground mb-6">{message}</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;
|
||||
}) {
|
||||
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>
|
||||
{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}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+37
-40
@@ -20,6 +20,16 @@
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--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-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
@@ -63,6 +73,16 @@
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: 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);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
@@ -98,6 +118,16 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.637 0.237 25.331);
|
||||
--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);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
@@ -116,6 +146,12 @@
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
/* Hide all scrollbars globally while keeping scroll functionality */
|
||||
*::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
@@ -161,50 +197,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode scrollbars */
|
||||
/* Dark mode 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 */
|
||||
[data-slot="dialog-content"] {
|
||||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
@@ -95,6 +95,8 @@ export const IPC_CHANNELS = {
|
||||
getAgent: "agent-company:get-agent",
|
||||
getRun: "agent-company:get-run",
|
||||
getApproval: "agent-company:get-approval",
|
||||
getConnectorDisabledModels: "agent-company:get-connector-disabled-models",
|
||||
setConnectorDisabledModels: "agent-company:set-connector-disabled-models",
|
||||
} as const;
|
||||
|
||||
const optionalId = z.preprocess(
|
||||
@@ -119,6 +121,11 @@ export const connectorInputSchema = z.object({
|
||||
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({
|
||||
id: optionalId,
|
||||
companyId: z.string().uuid(),
|
||||
@@ -697,6 +704,8 @@ export type DesktopApi = {
|
||||
deleteSecret: (input: z.infer<typeof deleteEntitySchema>) => Promise<DesktopResult<boolean>>;
|
||||
getSidebarBadges: (companyId: string) => Promise<DesktopResult<SidebarBadges>>;
|
||||
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>>;
|
||||
addComment: (input: z.infer<typeof commentInputSchema>) => Promise<DesktopResult<string>>;
|
||||
listComments: (input: z.infer<typeof listCommentsSchema>) => Promise<DesktopResult<CommentRecord[]>>;
|
||||
|
||||
Reference in new issue
Block a user