欢迎页面支持多语言

This commit is contained in:
xintaofei
2026-03-07 12:17:57 +08:00
parent aeecc4769c
commit 6c48be023c
15 changed files with 685 additions and 72 deletions

View File

@@ -2,6 +2,8 @@
import { useState } from "react"
import { open } from "@tauri-apps/plugin-dialog"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { cloneRepository, openFolderWindow } from "@/lib/tauri"
import {
Dialog,
@@ -14,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { FolderOpen, Loader2 } from "lucide-react"
import { resolveCloneError } from "@/components/welcome/error-utils"
interface CloneDialogProps {
open: boolean
@@ -21,10 +24,14 @@ interface CloneDialogProps {
}
export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
const t = useTranslations("WelcomePage")
const [url, setUrl] = useState("")
const [targetDir, setTargetDir] = useState("")
const [cloning, setCloning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [error, setError] = useState<{
message: string
detail: string | null
} | null>(null)
const handleBrowse = async () => {
const selected = await open({ directory: true, multiple: false })
@@ -52,8 +59,15 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
await openFolderWindow(fullPath)
onOpenChange(false)
resetForm()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} catch (err) {
const resolvedError = resolveCloneError(err)
setError({
message: t(resolvedError.key),
detail: resolvedError.detail ?? null,
})
toast.error(t("toasts.cloneFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
} finally {
setCloning(false)
}
@@ -75,15 +89,15 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Clone Repository</DialogTitle>
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="repo-url">Repository URL</Label>
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
<Input
id="repo-url"
placeholder="https://github.com/user/repo.git"
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={cloning}
@@ -91,11 +105,11 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
</div>
<div className="space-y-2">
<Label htmlFor="target-dir">Directory</Label>
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
<div className="flex gap-2">
<Input
id="target-dir"
placeholder="Select target directory..."
placeholder={t("cloneDialog.directoryPlaceholder")}
value={targetDir}
onChange={(e) => setTargetDir(e.target.value)}
disabled={cloning}
@@ -106,13 +120,23 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
size="icon"
onClick={handleBrowse}
disabled={cloning}
title={t("cloneDialog.browseDirectory")}
aria-label={t("cloneDialog.browseDirectory")}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && (
<div className="space-y-1">
<p className="text-sm text-destructive">{error.message}</p>
{error.detail && (
<p className="text-xs text-muted-foreground">{error.detail}</p>
)}
</div>
)}
</div>
<DialogFooter>
@@ -120,15 +144,17 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
variant="outline"
onClick={() => onOpenChange(false)}
disabled={cloning}
type="button"
>
Cancel
{t("cloneDialog.cancel")}
</Button>
<Button
onClick={handleClone}
disabled={!url || !targetDir || cloning}
type="button"
>
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Clone
{t("cloneDialog.clone")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -0,0 +1,146 @@
import { extractAppCommandError, toErrorMessage } from "@/lib/app-error"
export type WelcomeErrorKey =
| "errors.unknown"
| "errors.invalidInput"
| "errors.notFound"
| "errors.alreadyExists"
| "errors.dependencyMissing"
| "errors.databaseError"
| "errors.ioError"
| "errors.externalCommandFailed"
| "errors.windowOperationFailed"
| "errors.gitNotInstalled"
| "errors.targetDirectoryNotEmpty"
| "errors.repositoryNotFound"
| "errors.networkUnavailable"
| "errors.authenticationFailed"
| "errors.permissionDenied"
export interface WelcomeErrorResult {
key: WelcomeErrorKey
detail?: string
}
export function normalizeErrorMessage(error: unknown): string {
return toErrorMessage(error)
}
function stripClonePrefix(message: string): string {
return message.replace(/^clone failed:\s*/i, "").trim()
}
function mapCommonCodeToKey(code: string): WelcomeErrorKey {
switch (code) {
case "invalid_input":
return "errors.invalidInput"
case "not_found":
return "errors.notFound"
case "already_exists":
return "errors.alreadyExists"
case "permission_denied":
return "errors.permissionDenied"
case "dependency_missing":
return "errors.dependencyMissing"
case "network_error":
return "errors.networkUnavailable"
case "authentication_failed":
return "errors.authenticationFailed"
case "database_error":
return "errors.databaseError"
case "io_error":
return "errors.ioError"
case "external_command_failed":
return "errors.externalCommandFailed"
case "window_operation_failed":
return "errors.windowOperationFailed"
default:
return "errors.unknown"
}
}
export function resolveWelcomeError(error: unknown): WelcomeErrorResult {
const appError = extractAppCommandError(error)
if (appError) {
const key = mapCommonCodeToKey(appError.code)
const detail =
key === "errors.unknown" ? appError.detail || appError.message : undefined
return detail ? { key, detail } : { key }
}
return {
key: "errors.unknown",
detail: normalizeErrorMessage(error),
}
}
export function resolveCloneError(error: unknown): WelcomeErrorResult {
const appError = extractAppCommandError(error)
if (appError) {
switch (appError.code) {
case "dependency_missing":
return { key: "errors.gitNotInstalled" }
case "already_exists":
return { key: "errors.targetDirectoryNotEmpty" }
case "not_found":
return { key: "errors.repositoryNotFound" }
case "network_error":
return { key: "errors.networkUnavailable" }
case "authentication_failed":
return { key: "errors.authenticationFailed" }
case "permission_denied":
return { key: "errors.permissionDenied" }
default: {
const key = mapCommonCodeToKey(appError.code)
const detail =
key === "errors.unknown"
? appError.detail || appError.message
: undefined
return detail ? { key, detail } : { key }
}
}
}
const rawMessage = normalizeErrorMessage(error)
const message = stripClonePrefix(rawMessage)
const normalized = message.toLowerCase()
if (normalized.includes("git is not installed")) {
return { key: "errors.gitNotInstalled" }
}
if (normalized.includes("already exists and is not an empty directory")) {
return { key: "errors.targetDirectoryNotEmpty" }
}
if (normalized.includes("repository not found")) {
return { key: "errors.repositoryNotFound" }
}
if (
normalized.includes("could not resolve host") ||
normalized.includes("network is unreachable") ||
normalized.includes("connection timed out") ||
normalized.includes("failed to connect")
) {
return { key: "errors.networkUnavailable" }
}
if (
normalized.includes("authentication failed") ||
normalized.includes("could not read username") ||
normalized.includes("permission denied (publickey)")
) {
return { key: "errors.authenticationFailed" }
}
if (normalized.includes("permission denied")) {
return { key: "errors.permissionDenied" }
}
return {
key: "errors.unknown",
detail: message || rawMessage || undefined,
}
}

View File

@@ -2,18 +2,30 @@
import { useState } from "react"
import { FolderOpen, GitBranch } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { open } from "@tauri-apps/plugin-dialog"
import { openFolderWindow } from "@/lib/tauri"
import { Button } from "@/components/ui/button"
import { CloneDialog } from "./clone-dialog"
import { resolveWelcomeError } from "@/components/welcome/error-utils"
export function FolderActions() {
const t = useTranslations("WelcomePage")
const [cloneOpen, setCloneOpen] = useState(false)
const handleOpen = async () => {
const selected = await open({ directory: true, multiple: false })
if (selected) {
if (!selected) return
try {
await openFolderWindow(selected)
} catch (err) {
console.error("[FolderActions] failed to open folder:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openFolderFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
}
}
@@ -23,17 +35,19 @@ export function FolderActions() {
variant="ghost"
className="justify-start gap-2 h-9"
onClick={handleOpen}
type="button"
>
<FolderOpen className="h-4 w-4" />
Open Folder
{t("openFolder")}
</Button>
<Button
variant="ghost"
className="justify-start gap-2 h-9"
onClick={() => setCloneOpen(true)}
type="button"
>
<GitBranch className="h-4 w-4" />
Clone Repository
{t("cloneRepository")}
</Button>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />

View File

@@ -1,12 +1,15 @@
"use client"
import { useState } from "react"
import { useMemo, useState } from "react"
import { Search, X, FolderOpen } from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { zhCN } from "date-fns/locale"
import { enUS, zhCN, zhTW } from "date-fns/locale"
import { useLocale, useTranslations } from "next-intl"
import { toast } from "sonner"
import { openFolderWindow, removeFolderFromHistory } from "@/lib/tauri"
import type { FolderHistoryEntry } from "@/lib/types"
import { Input } from "@/components/ui/input"
import { resolveWelcomeError } from "@/components/welcome/error-utils"
interface FolderListProps {
history: FolderHistoryEntry[]
@@ -15,21 +18,32 @@ interface FolderListProps {
}
export function FolderList({ history, loading, onRefresh }: FolderListProps) {
const t = useTranslations("WelcomePage")
const locale = useLocale()
const [search, setSearch] = useState("")
const dateFnsLocale =
locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS
const filtered = search
? history.filter(
(h) =>
h.name.toLowerCase().includes(search.toLowerCase()) ||
h.path.toLowerCase().includes(search.toLowerCase())
)
: history
const filtered = useMemo(() => {
if (!search) return history
const lowerCaseSearch = search.toLowerCase()
return history.filter(
(h) =>
h.name.toLowerCase().includes(lowerCaseSearch) ||
h.path.toLowerCase().includes(lowerCaseSearch)
)
}, [history, search])
const handleOpen = async (path: string) => {
try {
await openFolderWindow(path)
} catch (e) {
console.error("Failed to open folder:", e)
} catch (err) {
console.error("Failed to open folder:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openFolderFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
}
}
@@ -40,6 +54,10 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
onRefresh()
} catch (err) {
console.error("Failed to remove folder:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.removeFromHistoryFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
}
}
@@ -48,7 +66,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索文件夹..."
placeholder={t("searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-9"
@@ -58,12 +76,12 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
Loading...
{t("loading")}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-sm text-muted-foreground gap-2">
<FolderOpen className="h-8 w-8" />
<span></span>
<span>{t("emptyFolders")}</span>
</div>
) : (
filtered.map((entry) => (
@@ -93,7 +111,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(entry.last_opened_at), {
addSuffix: true,
locale: zhCN,
locale: dateFnsLocale,
})}
</span>
</div>
@@ -101,7 +119,9 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-destructive/10 rounded shrink-0 mt-0.5"
onClick={(e) => handleRemove(e, entry.path)}
title="从历史中移除"
title={t("removeFromHistory")}
aria-label={t("removeFromHistory")}
type="button"
>
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</button>

View File

@@ -1,14 +1,19 @@
"use client"
import { MessageCircleCode } from "lucide-react"
import { useTranslations } from "next-intl"
export function SoftwareInfo() {
const t = useTranslations("WelcomePage")
return (
<div className="w-full flex gap-4 px-6 py-8">
<MessageCircleCode className="size-12" />
<div className="flex flex-col">
<span className="text-base">Codeg</span>
<span className="text-sm text-muted-foreground">version 0.0.1</span>
<span className="text-sm text-muted-foreground">
{t("softwareVersion", { version: "0.0.1" })}
</span>
</div>
</div>
)

View File

@@ -1,39 +1,48 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { Settings } from "lucide-react"
import { loadFolderHistory } from "@/lib/tauri"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { loadFolderHistory, openSettingsWindow } from "@/lib/tauri"
import type { FolderHistoryEntry } from "@/lib/types"
import { FolderList } from "@/components/welcome/folder-list"
import { FolderActions } from "@/components/welcome/folder-actions"
import { SoftwareInfo } from "@/components/welcome/software-info"
import { Button } from "@/components/ui/button"
import { openSettingsWindow } from "@/lib/tauri"
import { AppToaster } from "@/components/ui/app-toaster"
import { resolveWelcomeError } from "@/components/welcome/error-utils"
import { AppTitleBar } from "@/components/layout/app-title-bar"
export function WelcomeScreen() {
const t = useTranslations("WelcomePage")
const [history, setHistory] = useState<FolderHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const refreshHistory = async () => {
const refreshHistory = useCallback(async () => {
try {
setHistory(await loadFolderHistory())
} catch (err) {
console.error("[WelcomeScreen] failed to load folder history:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.loadFolderHistoryFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
setHistory([])
} finally {
setLoading(false)
}
}
}, [t])
useEffect(() => {
refreshHistory()
}, [])
}, [refreshHistory])
return (
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
<AppTitleBar
center={
<span className="text-sm font-bold tracking-tight">
使Codeg
</span>
<span className="text-sm font-bold tracking-tight">{t("title")}</span>
}
right={
<Button
@@ -43,9 +52,15 @@ export function WelcomeScreen() {
onClick={() => {
openSettingsWindow().catch((err) => {
console.error("[WelcomeScreen] failed to open settings:", err)
const resolvedError = resolveWelcomeError(err)
toast.error(t("toasts.openSettingsFailed"), {
description: resolvedError.detail ?? t(resolvedError.key),
})
})
}}
title="Open Settings"
title={t("openSettings")}
aria-label={t("openSettings")}
type="button"
>
<Settings className="h-3.5 w-3.5" />
</Button>
@@ -63,6 +78,7 @@ export function WelcomeScreen() {
onRefresh={refreshHistory}
/>
</div>
<AppToaster position="bottom-right" closeButton duration={4000} />
</div>
)
}

View File

@@ -5,6 +5,51 @@
"simplifiedChinese": "Simplified Chinese",
"traditionalChinese": "Traditional Chinese"
},
"WelcomePage": {
"title": "Welcome to Codeg",
"openSettings": "Open Settings",
"searchPlaceholder": "Search folders...",
"loading": "Loading...",
"emptyFolders": "No folders yet",
"removeFromHistory": "Remove from history",
"openFolder": "Open Folder",
"cloneRepository": "Clone Repository",
"softwareVersion": "version {version}",
"toasts": {
"loadFolderHistoryFailed": "Failed to load folder history",
"openFolderFailed": "Failed to open folder",
"removeFromHistoryFailed": "Failed to remove folder",
"openSettingsFailed": "Failed to open settings",
"cloneFailed": "Failed to clone repository"
},
"errors": {
"unknown": "Unexpected error",
"invalidInput": "Invalid input.",
"notFound": "Resource not found.",
"alreadyExists": "Resource already exists.",
"dependencyMissing": "Required dependency is missing.",
"databaseError": "Database operation failed.",
"ioError": "File operation failed.",
"externalCommandFailed": "External command failed.",
"windowOperationFailed": "Window operation failed.",
"gitNotInstalled": "Git is not installed. Please install Git first.",
"targetDirectoryNotEmpty": "Target directory already exists and is not empty.",
"repositoryNotFound": "Repository not found. Check URL and access permissions.",
"networkUnavailable": "Network is unavailable. Check your connection and try again.",
"authenticationFailed": "Authentication failed. Check credentials or SSH key.",
"permissionDenied": "Permission denied. Check directory permissions."
},
"cloneDialog": {
"title": "Clone Repository",
"repositoryUrl": "Repository URL",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Directory",
"directoryPlaceholder": "Select target directory...",
"browseDirectory": "Browse directory",
"cancel": "Cancel",
"clone": "Clone"
}
},
"SettingsShell": {
"title": "Settings",
"preferences": "Preferences",

View File

@@ -5,6 +5,51 @@
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文"
},
"WelcomePage": {
"title": "欢迎使用Codeg",
"openSettings": "打开设置",
"searchPlaceholder": "搜索文件夹...",
"loading": "加载中...",
"emptyFolders": "暂无文件夹",
"removeFromHistory": "从历史中移除",
"openFolder": "打开文件夹",
"cloneRepository": "克隆仓库",
"softwareVersion": "版本 {version}",
"toasts": {
"loadFolderHistoryFailed": "加载文件夹历史失败",
"openFolderFailed": "打开文件夹失败",
"removeFromHistoryFailed": "移除历史记录失败",
"openSettingsFailed": "打开设置失败",
"cloneFailed": "克隆仓库失败"
},
"errors": {
"unknown": "发生未知错误",
"invalidInput": "输入无效。",
"notFound": "资源不存在。",
"alreadyExists": "资源已存在。",
"dependencyMissing": "缺少必要依赖。",
"databaseError": "数据库操作失败。",
"ioError": "文件操作失败。",
"externalCommandFailed": "外部命令执行失败。",
"windowOperationFailed": "窗口操作失败。",
"gitNotInstalled": "未检测到 Git请先安装 Git。",
"targetDirectoryNotEmpty": "目标目录已存在且不为空。",
"repositoryNotFound": "仓库不存在,请检查地址和访问权限。",
"networkUnavailable": "网络不可用,请检查网络连接后重试。",
"authenticationFailed": "认证失败,请检查凭据或 SSH Key。",
"permissionDenied": "权限不足,请检查目录权限。"
},
"cloneDialog": {
"title": "克隆仓库",
"repositoryUrl": "仓库地址",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "目录",
"directoryPlaceholder": "选择目标目录...",
"browseDirectory": "浏览目录",
"cancel": "取消",
"clone": "克隆"
}
},
"SettingsShell": {
"title": "设置",
"preferences": "偏好设置",

View File

@@ -5,6 +5,51 @@
"simplifiedChinese": "简体中文",
"traditionalChinese": "繁體中文"
},
"WelcomePage": {
"title": "歡迎使用Codeg",
"openSettings": "打開設定",
"searchPlaceholder": "搜尋資料夾...",
"loading": "載入中...",
"emptyFolders": "暫無資料夾",
"removeFromHistory": "從歷史中移除",
"openFolder": "打開資料夾",
"cloneRepository": "複製倉庫",
"softwareVersion": "版本 {version}",
"toasts": {
"loadFolderHistoryFailed": "載入資料夾歷史失敗",
"openFolderFailed": "打開資料夾失敗",
"removeFromHistoryFailed": "移除歷史記錄失敗",
"openSettingsFailed": "打開設定失敗",
"cloneFailed": "複製倉庫失敗"
},
"errors": {
"unknown": "發生未知錯誤",
"invalidInput": "輸入無效。",
"notFound": "資源不存在。",
"alreadyExists": "資源已存在。",
"dependencyMissing": "缺少必要依賴。",
"databaseError": "資料庫操作失敗。",
"ioError": "檔案操作失敗。",
"externalCommandFailed": "外部指令執行失敗。",
"windowOperationFailed": "視窗操作失敗。",
"gitNotInstalled": "未檢測到 Git請先安裝 Git。",
"targetDirectoryNotEmpty": "目標目錄已存在且不為空。",
"repositoryNotFound": "倉庫不存在,請檢查地址與存取權限。",
"networkUnavailable": "網路不可用,請檢查網路連線後重試。",
"authenticationFailed": "驗證失敗,請檢查憑證或 SSH Key。",
"permissionDenied": "權限不足,請檢查目錄權限。"
},
"cloneDialog": {
"title": "複製倉庫",
"repositoryUrl": "倉庫地址",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "目錄",
"directoryPlaceholder": "選擇目標目錄...",
"browseDirectory": "瀏覽目錄",
"cancel": "取消",
"clone": "複製"
}
},
"SettingsShell": {
"title": "設定",
"preferences": "偏好設定",

69
src/lib/app-error.ts Normal file
View File

@@ -0,0 +1,69 @@
import type { AppCommandError } from "@/lib/types"
type ObjectLike = Record<string, unknown>
function asObject(value: unknown): ObjectLike | null {
return value !== null && typeof value === "object"
? (value as ObjectLike)
: null
}
function normalizeString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null
}
function parseErrorObject(value: unknown): AppCommandError | null {
const obj = asObject(value)
if (!obj) return null
const code = normalizeString(obj.code)
const message = normalizeString(obj.message)
const detailRaw = normalizeString(obj.detail)
const detail = detailRaw ?? null
if (!code || !message) return null
return {
code,
message,
detail,
}
}
export function extractAppCommandError(error: unknown): AppCommandError | null {
const direct = parseErrorObject(error)
if (direct) return direct
const errorObject = asObject(error)
const message = normalizeString(errorObject?.message)
if (!message) return null
try {
const parsed = JSON.parse(message)
return parseErrorObject(parsed)
} catch {
return null
}
}
export function toErrorMessage(error: unknown): string {
const appError = extractAppCommandError(error)
if (appError) {
return appError.detail?.trim() || appError.message
}
if (error instanceof Error) {
return error.message.trim()
}
if (typeof error === "string") {
return error.trim()
}
try {
const serialized = JSON.stringify(error)
return serialized ? serialized.trim() : String(error)
} catch {
return String(error)
}
}

View File

@@ -20,6 +20,27 @@ export type AgentType =
| "open_claw"
| "stakpak"
export type AppErrorCode =
| "unknown"
| "invalid_input"
| "not_found"
| "already_exists"
| "permission_denied"
| "dependency_missing"
| "network_error"
| "authentication_failed"
| "database_error"
| "io_error"
| "external_command_failed"
| "window_operation_failed"
| (string & {})
export interface AppCommandError {
code: AppErrorCode
message: string
detail?: string | null
}
export interface ConversationSummary {
id: string
agent_type: AgentType