jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

294 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* StatusView — 狀態頁AF3 實作)
*
* 對齊 spec §4
* - StatusHero大狀態指示器5 變體)
* - InfoCard帳號 / Relay / 連線開始 / Session Token 遮蔽版)
* - 主按鈕列(依狀態五套)
* - RecentLog最近 10 筆事件)
* - 未配對時顯示 EmptyState + 「前往配對」CTA
*
* 資料來源AF3 階段 mockAF6 會換成真 Wails binding
* - useConnectionStatus() → snapshot
* - useRecentLogs(10) → logs
*
* 與配對 Tab 的互動:
* - 「前往配對」按鈕透過 Radix Tabs 的 `TabsContext` 無法直接 setValue因為我們用 defaultValue
* 改為 dispatch CustomEvent `agent:switch-tab` 由 page.tsx 攔截切換。
* 這一層間接呼叫雖然比 prop-drilling 多一步,但解耦了 view 與 app 結構,
* 未來 StatusView 被放到其他容器也不會壞。
*/
import { Unlink } from "lucide-react";
import { toast } from "sonner";
import { InfoCard } from "@/components/agent/info-card";
import { RecentLog } from "@/components/agent/recent-log";
import { StatusHero } from "@/components/agent/status-hero";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Spinner } from "@/components/ui/spinner";
import { useConnectionStatus } from "@/hooks/use-connection-status";
import { useRecentLogs } from "@/hooks/use-recent-logs";
import { agentAPI } from "@/lib/agent-api";
import { useT } from "@/lib/i18n/context";
/** 觸發切換到配對 tab 的自訂事件名page.tsx 監聽)。 */
export const AGENT_SWITCH_TAB_EVENT = "agent:switch-tab";
/** 發送 CustomEvent 切換 tabexport 給其他 view 共用,避免重複定義 helper。 */
export function switchAgentTab(value: "status" | "pair" | "settings") {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<{ value: string }>(AGENT_SWITCH_TAB_EVENT, {
detail: { value },
}),
);
}
/** 內部別名(保留舊名以利 view 內部讀起來語意清楚)。 */
const switchTab = switchAgentTab;
export function StatusView() {
const t = useT();
const { snapshot, loading } = useConnectionStatus();
const { logs } = useRecentLogs(10);
/* ---------- Loading初次拉 snapshot---------- */
if (loading || !snapshot) {
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="flex flex-col items-center gap-3 py-12"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<Spinner size="lg" label={t("common.loading")} />
</section>
);
}
/* ---------- 空狀態尚未配對spec §4.3---------- */
if (snapshot.state === "notPaired") {
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="py-6"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<EmptyState
icon={Unlink}
title={t("status.empty.title")}
description={t("status.empty.description")}
action={{
label: t("status.action.goPair"),
onClick: () => switchTab("pair"),
}}
/>
</section>
);
}
/* ---------- 正常狀態online / offline / reconnecting / error---------- */
return (
<section
data-testid="status-view"
aria-labelledby="status-view-heading"
className="flex flex-col gap-6 py-4"
>
<h2 id="status-view-heading" className="sr-only">
{t("nav.status")}
</h2>
<StatusHero
state={snapshot.state}
attemptNo={snapshot.attemptNo}
errorMessage={snapshot.error}
/>
<Card>
<CardContent className="space-y-4">
<InfoCard snapshot={snapshot} />
<ActionButtons state={snapshot.state} />
</CardContent>
</Card>
<RecentLog logs={logs} max={10} />
</section>
);
}
/* -------------------------------------------------------------------------- */
/* ActionButtons — 主按鈕列(依狀態 5 套) */
/* -------------------------------------------------------------------------- */
interface ActionButtonsProps {
state: "online" | "offline" | "connecting" | "reconnecting" | "error" | "notPaired";
}
function ActionButtons({ state }: ActionButtonsProps) {
const t = useT();
// online 與 offline / reconnecting 的主按鈕不同,用 switch 明確分岔
switch (state) {
case "online":
return (
<div className="flex flex-wrap gap-2">
<DisconnectButton />
</div>
);
case "offline":
return (
<div className="flex flex-wrap gap-2">
<Button
data-testid="status-action-reconnect"
variant="default"
onClick={() => {
// AF6手動重連失敗時 toast成功由 connection:status event 更新 UI
agentAPI.reconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.reconnect")}
</Button>
<RepairButton />
</div>
);
case "reconnecting":
case "connecting":
return (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="status-action-reconnect"
variant="outline"
disabled
>
<Spinner size="sm" label={t("common.loading")} />
<span>{t("connection.reconnecting")}</span>
</Button>
</div>
);
case "error":
return (
<div className="flex flex-wrap gap-2">
<Button
data-testid="status-action-retry"
variant="default"
onClick={() => {
// AF6錯誤狀態下的重試 = 手動重連
agentAPI.reconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.retry")}
</Button>
<RepairButton />
</div>
);
default:
// notPaired 不會到這裡(外層 early-return 了),保留 fallback 讓 switch 完整
return null;
}
}
/**
* 「重新配對」次按鈕spec §4.2 C / Fix-F1
*
* 顯示時機error / offline。其他狀態下 ActionButtons 不會 render 這顆。
*
* 行為:點擊後切到配對 tab不直接呼叫 unpairspec §4.2 C 把 unpair 的破壞性
* 動作留在配對流程裡顯式發生,避免使用者誤點次按鈕就清掉 Session
*
* 樣式variant="outline"(次按鈕,不跟主按鈕搶眼)。
*/
function RepairButton() {
const t = useT();
return (
<Button
type="button"
variant="outline"
data-testid="status-action-repair"
aria-label={t("status.action.repair")}
title={t("status.action.repair")}
onClick={() => switchTab("pair")}
>
{t("status.action.repair")}
</Button>
);
}
/** Disconnect 按鈕 — 透過 AlertDialog 確認。 */
function DisconnectButton() {
const t = useT();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
data-testid="status-action-disconnect"
>
{t("status.action.disconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("status.confirm.disconnect.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("status.confirm.disconnect.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
data-testid="status-action-disconnect-confirm"
onClick={() => {
// AF6呼叫 Wails Disconnect binding
// 成功connection:status event 會推 "offline" 更新 UI失敗toast
agentAPI.disconnect().catch((err) => {
toast.error(
err instanceof Error ? err.message : String(err),
);
});
}}
>
{t("status.action.disconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}