fix(v02): compact workbench status panels (#838)

Co-authored-by: Codex Agent <codex@hwlab.local>
This commit is contained in:
Lyon
2026-06-04 17:47:56 +08:00
committed by GitHub
parent be557c09bc
commit 2fc3972ddd
11 changed files with 377 additions and 83 deletions
+9 -1
View File
@@ -16,6 +16,8 @@ import { SessionSidebar } from "./components/sessions/SessionSidebar";
import { SettingsView } from "./components/settings/SettingsView";
import { SkillsView } from "./components/skills/SkillsView";
import { GateView } from "./components/gate/GateView";
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
import { fetchText } from "./services/api/client";
import { firstNonEmptyString } from "./utils";
@@ -127,8 +129,14 @@ export function App(): ReactElement {
<header className="workspace-topbar" id="workspace-topbar" aria-label="主工作区状态栏">
<button className="workspace-topbar-button" id="left-sidebar-toggle" type="button" aria-controls="session-sidebar" aria-expanded={sessionSidebarVisible} aria-label={leftCollapsed ? "展开左侧 Session sidebar" : "折叠左侧 Session sidebar"} title={leftCollapsed ? "展开左侧 Session sidebar" : "折叠左侧 Session sidebar"} disabled={!workspaceRoute} onClick={() => setLeftCollapsed((value) => !value)}>{leftCollapsed ? "Sessions" : "隐藏 Sessions"}</button>
<div className="workspace-topbar-status">
<span>{routeLabels[route]}</span>
<span className="workspace-route-label">{routeLabels[route]}</span>
<StatePill tone={store.state.chatPending ? "pending" : "source"}>{store.state.chatPending ? "处理中" : modelLabel}</StatePill>
{workspaceRoute ? (
<div className="topbar-status-tools" aria-label="工作区实时状态">
<WorkbenchProbe live={store.state.live} />
<WorkbenchBuildSummary live={store.state.live} />
</div>
) : null}
</div>
<button className="workspace-topbar-button" id="right-sidebar-toggle" type="button" aria-controls="device-pod-sidebar" aria-expanded={devicePodSidebarVisible} aria-label={rightCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} title={rightCollapsed ? "展开右侧 Device Pod 看板" : "折叠右侧 Device Pod 看板"} disabled={!workspaceRoute} onClick={() => setRightCollapsed((value) => !value)}>{rightCollapsed ? "Device Pod" : "隐藏 Device Pod"}</button>
</header>
@@ -5,8 +5,6 @@ import { devicePodsFromLive } from "../../state/workbench";
import type { DevicePodEvent, DevicePodInterface, DevicePodItem, DevicePodStatusResponse, LiveSurface } from "../../types/domain";
import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
import { WorkbenchBuildSummary } from "../workbench/WorkbenchBuildSummary";
import { WorkbenchProbe } from "../workbench/WorkbenchProbe";
interface DevicePodSidebarProps {
live: LiveSurface | null;
@@ -26,8 +24,6 @@ export function DevicePodSidebar({ live, selectedDevicePodId, onSelect, collapse
return (
<aside className={`right-sidebar${collapsed ? " is-device-pod-collapsed" : ""}`} id="device-pod-sidebar" aria-label="Device Pod 摘要看板" aria-hidden={collapsed}>
<div className="right-sidebar-content">
<WorkbenchProbe live={live} />
<WorkbenchBuildSummary live={live} />
<header className="right-sidebar-head">
<div>
<p className="eyebrow">Device Pod</p>
@@ -1,13 +1,13 @@
import type { ReactElement } from "react";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import type { Tone } from "../../types/domain";
interface StateTagProps {
interface StateTagProps extends HTMLAttributes<HTMLSpanElement> {
tone: Tone | string;
children: React.ReactNode;
children: ReactNode;
id?: string;
title?: string;
}
export function StateTag({ tone, children, id, title }: StateTagProps): ReactElement {
return <span id={id} title={title} className={`state-tag tone-${tone}`}>{children}</span>;
export function StateTag({ tone, children, id, title, className, ...attributes }: StateTagProps): ReactElement {
return <span id={id} title={title} className={`state-tag tone-${tone}${className ? ` ${className}` : ""}`} {...attributes}>{children}</span>;
}
@@ -0,0 +1,29 @@
import type { ReactNode } from "react";
interface WorkbenchDialogProps {
id?: string;
title: string;
children: ReactNode;
onClose: () => void;
}
export function WorkbenchDialog({ id, title, children, onClose }: WorkbenchDialogProps) {
return (
<div className="workbench-dialog-layer" role="presentation" onClick={onClose}>
<section
id={id}
className="workbench-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(event) => event.stopPropagation()}
>
<header className="workbench-dialog-head">
<h2>{title}</h2>
<button className="workbench-dialog-close" type="button" aria-label="关闭" onClick={onClose}>×</button>
</header>
<div className="workbench-dialog-content">{children}</div>
</section>
</div>
);
}
@@ -1,8 +1,10 @@
import type { ReactElement } from "react";
import { useState } from "react";
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
import { formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
import type { LiveBuildRecord, LiveProbePayload, LiveSurface, Tone } from "../../types/domain";
import { asRecord, firstNonEmptyString, formatBeijingTime, formatDuration, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
import { WorkbenchDialog } from "../shared/WorkbenchDialog";
interface WorkbenchBuildSummaryProps {
live: LiveSurface | null;
@@ -13,68 +15,228 @@ interface ServiceRow {
label: string;
serviceId: string | null;
commitShort: string | null;
actualCommit: string | null;
actualCommitShort: string | null;
envCommitShort: string | null;
revisionShort: string | null;
tag: string | null;
envImageTag: string | null;
imageDigestShort: string | null;
builtAt: string | null;
status: "ok" | "pending" | "blocked" | "source" | "warn";
status: Tone;
reason: string | null;
source: string | null;
rollout: string | null;
}
export function WorkbenchBuildSummary({ live }: WorkbenchBuildSummaryProps): ReactElement {
const data = live?.healthLive.data;
const service = data?.service;
const build = data?.build;
const commit = data?.commit;
const image = data?.image;
const row = buildRowFromLive(service, commit, image, build);
const overall = row.status;
const [open, setOpen] = useState(false);
const rows = buildRows(live);
const latest = latestRow(live, rows);
const overall = rows.some((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked") ? "blocked" : "pending";
const latestText = latestSummaryText(latest);
const countsText = countsSummary(live?.liveBuilds.data?.counts);
return (
<section className={`live-build-summary live-build-summary-${overall} tone-border-${toneClass(overall)}`} data-live-build-summary>
<header className="live-build-summary-head">
<p className="eyebrow"></p>
<StateTag tone={toneClass(overall)} data-live-build-tone>{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</StateTag>
</header>
<ul className="live-build-list" role="list">
<li className="live-build-row" role="listitem" data-live-build-row={row.key}>
<div className="live-build-row-head">
<span className={`live-build-tone tone-${toneClass(overall)}`} aria-hidden="true"></span>
<span className="live-build-service">{row.label}</span>
<span className="live-build-time">{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</span>
<section className={`live-build-summary live-build-summary-compact live-build-summary-${overall} tone-border-${toneClass(overall)}`} data-live-build-summary>
<details id="live-build-summary" className="live-build-details" open={false}>
<summary id="live-build-latest" className="live-build-summary-line" onClick={(event) => event.preventDefault()}>
<button
id="live-build-toggle"
className="status-chip-button live-build-summary-button"
type="button"
aria-haspopup="dialog"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(true);
}}
>
<span className="status-chip-label"></span>
<StateTag tone={toneClass(overall)} data-live-build-tone>{latest.builtAt ? formatBeijingTime(latest.builtAt, { withSeconds: true }) : "构建时间不可用"}</StateTag>
<span className="status-chip-meta">{latestText}</span>
</button>
</summary>
<ul id="live-build-list" className="live-build-list" role="list" hidden>
{rows.map((row) => <BuildRowItem key={row.key} row={row} />)}
</ul>
</details>
{open ? (
<WorkbenchDialog id="live-build-dialog" title="构建版本明细" onClose={() => setOpen(false)}>
<div className="live-build-dialog-summary">
<p>{latestText}</p>
{countsText ? <p>{countsText}</p> : null}
</div>
<div className="live-build-meta">
<span>tag {row.tag ?? "unknown"}</span>
<span>commit {row.commitShort ?? "unknown"}</span>
<span>revision {row.revisionShort ?? "unknown"}</span>
{row.source ? <span> {row.source}</span> : null}
</div>
{row.reason ? <p className="live-build-reason">{row.reason}</p> : null}
</li>
</ul>
<ul className="live-build-list live-build-list-dialog" role="list">
{rows.map((row) => <BuildRowItem key={row.key} row={row} />)}
</ul>
</WorkbenchDialog>
) : null}
</section>
);
}
function buildRowFromLive(service: LiveProbePayload["service"] | undefined, commit: LiveProbePayload["commit"] | undefined, image: LiveProbePayload["image"] | undefined, build: LiveProbePayload["build"] | undefined): ServiceRow {
const label = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : "cloud-api";
const serviceId = (service && typeof service === "object" && "id" in service && typeof service.id === "string") ? service.id : null;
const commitShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
const revisionShort = (commit && typeof commit === "object" && "id" in commit && typeof commit.id === "string") ? shortToken(commit.id, 12) : null;
const tag = (image && typeof image === "object" && "tag" in image && typeof image.tag === "string") ? image.tag : null;
const builtAt = (build && typeof build === "object" && "createdAt" in build && typeof build.createdAt === "string") ? build.createdAt : null;
const unavailableReason = (build && typeof build === "object" && "unavailableReason" in build && typeof build.unavailableReason === "string") ? build.unavailableReason : null;
const source = (build && typeof build === "object" && "metadataSource" in build && typeof build.metadataSource === "string") ? build.metadataSource : null;
function buildRows(live: LiveSurface | null): ServiceRow[] {
const services = live?.liveBuilds.data?.services;
if (services?.length) return services.map(rowFromLiveBuildRecord);
const data = live?.healthLive.data;
return [buildRowFromLive(data?.service, data?.commit, data?.image, data?.build, data?.revision, "health-live")];
}
function latestRow(live: LiveSurface | null, rows: ServiceRow[]): ServiceRow {
const latest = live?.liveBuilds.data?.latest;
if (latest) return rowFromLiveBuildRecord(latest);
return rows.find((row) => row.builtAt) ?? rows[0] ?? emptyRow();
}
function rowFromLiveBuildRecord(record: LiveBuildRecord): ServiceRow {
const build = asRecord(record.build);
const image = asRecord(record.image);
const commit = asRecord(record.commit);
const desiredState = asRecord(record.desiredState);
const liveMetadataMatch = asRecord(build?.liveMetadataMatch);
const metadata = asRecord(liveMetadataMatch?.metadata);
const serviceId = firstNonEmptyString(record.serviceId, record.name);
const label = serviceId ?? "unknown-service";
const actualCommit = firstNonEmptyString(commit?.id, record.revision);
const envCommit = firstNonEmptyString(metadata?.commitId, metadata?.revision);
const builtAt = firstNonEmptyString(build?.createdAt);
const tag = firstNonEmptyString(image?.tag);
const reason = firstNonEmptyString(build?.unavailableReason, record.reason);
const source = sourceLabel(firstNonEmptyString(build?.metadataSource), record.healthUrl);
return {
key: label,
key: serviceId ?? `${record.kind ?? "service"}-${tag ?? label}`,
label,
serviceId,
commitShort,
revisionShort,
commitShort: shortNullable(actualCommit, 12),
actualCommit,
actualCommitShort: shortNullable(actualCommit, 12),
envCommitShort: shortNullable(envCommit, 12),
revisionShort: shortNullable(record.revision, 12),
tag,
envImageTag: envImageLabel(tag, build?.source),
imageDigestShort: shortNullable(image?.digest, 18),
builtAt,
status: builtAt ? "ok" : service ? "pending" : "blocked",
reason: unavailableReason ?? null,
source
status: builtAt ? "ok" : reason ? "warn" : toneClass(record.status),
reason,
source,
rollout: rolloutLabel(desiredState)
};
}
function buildRowFromLive(service: unknown, commit: unknown, image: unknown, build: unknown, revision: unknown, keyFallback: string): ServiceRow {
const serviceRecord = asRecord(service);
const commitRecord = asRecord(commit);
const imageRecord = asRecord(image);
const buildRecord = asRecord(build);
const label = firstNonEmptyString(serviceRecord?.id, serviceRecord?.serviceId) ?? "cloud-api";
const actualCommit = firstNonEmptyString(commitRecord?.id, revision);
const builtAt = firstNonEmptyString(buildRecord?.createdAt);
const tag = firstNonEmptyString(imageRecord?.tag);
const reason = firstNonEmptyString(buildRecord?.unavailableReason);
return {
key: label ?? keyFallback,
label,
serviceId: label,
commitShort: shortNullable(actualCommit, 12),
actualCommit,
actualCommitShort: shortNullable(actualCommit, 12),
envCommitShort: null,
revisionShort: shortNullable(revision, 12),
tag,
envImageTag: tag,
imageDigestShort: shortNullable(imageRecord?.digest, 18),
builtAt,
status: builtAt ? "ok" : serviceRecord ? "pending" : "blocked",
reason,
source: sourceLabel(firstNonEmptyString(buildRecord?.metadataSource), "/health/live"),
rollout: null
};
}
function BuildRowItem({ row }: { row: ServiceRow }): ReactElement {
return (
<li className="live-build-row" role="listitem" data-live-build-row={row.key}>
<div className="live-build-row-head">
<span className={`live-build-tone tone-${toneClass(row.status)}`} aria-hidden="true"></span>
<span className="live-build-service">{row.label}</span>
<span className="live-build-time">{row.builtAt ? formatBeijingTime(row.builtAt, { withSeconds: true }) : "构建时间不可用"}</span>
</div>
<div className="live-build-meta">
<span>tag {row.tag ?? "unknown"}</span>
<span>env {row.envImageTag ?? "unknown"}</span>
<span> commit {row.actualCommitShort ?? row.commitShort ?? "unknown"}</span>
<span>commit {row.commitShort ?? "unknown"}</span>
<span>env commit {row.envCommitShort ?? "unknown"}</span>
<span>revision {row.revisionShort ?? "unknown"}</span>
{row.imageDigestShort ? <span>digest {row.imageDigestShort}</span> : null}
{row.source ? <span> {row.source}</span> : null}
{row.rollout ? <span>rollout {row.rollout}</span> : null}
</div>
{row.reason ? <p className="live-build-reason">{row.reason}</p> : null}
</li>
);
}
function latestSummaryText(row: ServiceRow): string {
const time = row.builtAt ? `${formatBeijingTime(row.builtAt, { withSeconds: true })} 北京时间` : "构建时间不可用";
return `最新镜像构建时间:${time} · ${row.label} · tag ${row.tag ?? "unknown"} · commit ${row.commitShort ?? "unknown"} · env ${row.envImageTag ?? row.envCommitShort ?? "unknown"} · actual ${row.actualCommitShort ?? "unknown"} · revision ${row.revisionShort ?? "unknown"}`;
}
function countsSummary(counts: Record<string, unknown> | null | undefined): string | null {
if (!counts) return null;
const total = counts.total;
const withBuildTime = counts.withBuildTime;
const unavailable = counts.unavailable;
const external = counts.external;
return `服务 ${total ?? "unknown"} 个,可见构建时间 ${withBuildTime ?? "unknown"} 个,缺失 ${unavailable ?? "unknown"} 个,外部 ${external ?? "unknown"}`;
}
function sourceLabel(metadataSource: string | null, healthUrl: unknown): string | null {
const parts = [];
if (firstNonEmptyString(healthUrl)) parts.push("live health");
if (metadataSource) parts.push(metadataSource);
return parts.length ? parts.join(" / ") : null;
}
function rolloutLabel(desiredState: Record<string, unknown> | null): string | null {
if (!desiredState) return null;
const namespace = firstNonEmptyString(desiredState.namespace);
const profile = firstNonEmptyString(desiredState.profile);
const replicas = desiredState.replicas;
const source = firstNonEmptyString(desiredState.source);
return [namespace, profile, Number.isFinite(Number(replicas)) ? `${replicas} replicas` : null, source].filter(Boolean).join(" / ") || null;
}
function envImageLabel(tag: string | null, source: unknown): string | null {
const sourceText = firstNonEmptyString(source);
const envFromSource = sourceText?.match(/@env:([^\s/]+)/u)?.[1] ?? null;
return firstNonEmptyString(tag, envFromSource);
}
function shortNullable(value: unknown, length: number): string | null {
const text = firstNonEmptyString(value);
return text ? shortToken(text, length) : null;
}
function emptyRow(): ServiceRow {
return {
key: "unknown",
label: "unknown-service",
serviceId: null,
commitShort: null,
actualCommit: null,
actualCommitShort: null,
envCommitShort: null,
revisionShort: null,
tag: null,
envImageTag: null,
imageDigestShort: null,
builtAt: null,
status: "pending",
reason: null,
source: null,
rollout: null
};
}
@@ -1,8 +1,10 @@
import type { ReactElement } from "react";
import { useState } from "react";
import type { LiveProbePayload, LiveSurface } from "../../types/domain";
import { formatBeijingTime, shortToken, toneClass } from "../../utils";
import { StateTag } from "../shared/StateTag";
import { WorkbenchDialog } from "../shared/WorkbenchDialog";
interface WorkbenchProbeProps {
live: LiveSurface | null;
@@ -27,6 +29,7 @@ interface ProbeResult {
}
export function WorkbenchProbe({ live }: WorkbenchProbeProps): ReactElement {
const [open, setOpen] = useState(false);
const rows: ProbeRow[] = [
rowFromProbe("cloud-api-live", "Cloud API 实况身份", "GET", "/health/live", live?.healthLive ?? null),
rowFromProbe("cloud-api-health", "Cloud API health", "GET", "/health", live?.health ?? null),
@@ -34,33 +37,44 @@ export function WorkbenchProbe({ live }: WorkbenchProbeProps): ReactElement {
rowFromProbe("adapter", "Codex app-server adapter", "POST", "/v1/rpc/system.health", live?.adapter ?? null)
];
const tone = rows.every((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked") ? "blocked" : "pending";
const okCount = rows.filter((row) => row.status === "ok").length;
const checkedAt = live?.loadedAt ? formatBeijingTime(live.loadedAt, { withSeconds: true }) : "未观测";
return (
<section className={`probe-card probe-card-${tone} tone-border-${toneClass(tone)}`} data-probe-card>
<header className="probe-card-head">
<p className="eyebrow"></p>
<section className={`probe-card probe-card-compact probe-card-${tone} tone-border-${toneClass(tone)}`} data-probe-card>
<button className="status-chip-button probe-summary-button" type="button" aria-haspopup="dialog" onClick={() => setOpen(true)}>
<span className="status-chip-label"></span>
<StateTag tone={toneClass(tone)} data-probe-card-tone>{tone === "ok" ? "健康" : tone === "blocked" ? "阻塞" : "等待验证"}</StateTag>
</header>
<ul className="probe-list" role="list">
{rows.map((row) => (
<li key={row.key} className="probe-row" role="listitem" data-probe-row={row.key}>
<div className="probe-row-head">
<span className={`probe-tone tone-${toneClass(row.status)}`} aria-hidden="true"></span>
<span className="probe-row-label">{row.label}</span>
<span className={`probe-row-status tone-${toneClass(row.status)}`}>{row.status === "ok" ? "通过" : row.status === "blocked" ? "阻塞" : "等待"}</span>
</div>
<div className="probe-row-meta">
<span className="probe-row-method">{row.method}</span>
<span className="probe-row-path mono">{row.path}</span>
<span className="probe-row-time">{row.lastCheckedAt ? formatBeijingTime(row.lastCheckedAt, { withSeconds: true }) : "未观测"}</span>
</div>
<p className="probe-row-detail">{row.detail}</p>
</li>
))}
</ul>
<span className="status-chip-meta">{okCount}/{rows.length} · {checkedAt}</span>
</button>
{open ? (
<WorkbenchDialog id="probe-status-dialog" title="工作区状态明细" onClose={() => setOpen(false)}>
<ul className="probe-list probe-list-dialog" role="list">
{rows.map((row) => <ProbeListItem key={row.key} row={row} />)}
</ul>
</WorkbenchDialog>
) : null}
</section>
);
}
function ProbeListItem({ row }: { row: ProbeRow }): ReactElement {
return (
<li className="probe-row" role="listitem" data-probe-row={row.key}>
<div className="probe-row-head">
<span className={`probe-tone tone-${toneClass(row.status)}`} aria-hidden="true"></span>
<span className="probe-row-label">{row.label}</span>
<span className={`probe-row-status tone-${toneClass(row.status)}`}>{row.status === "ok" ? "通过" : row.status === "blocked" ? "阻塞" : "等待"}</span>
</div>
<div className="probe-row-meta">
<span className="probe-row-method">{row.method}</span>
<span className="probe-row-path mono">{row.path}</span>
<span className="probe-row-time">{row.lastCheckedAt ? formatBeijingTime(row.lastCheckedAt, { withSeconds: true }) : "未观测"}</span>
</div>
<p className="probe-row-detail">{row.detail}</p>
</li>
);
}
function rowFromProbe(key: string, label: string, method: string, path: string, probe: ProbeResult | null): ProbeRow {
const data = probe?.data ?? null;
const ok = Boolean(probe?.ok) && (data?.ready === true || data?.status === "ok" || data?.status === "ready");
@@ -6,6 +6,7 @@ import type {
DevicePodEventsResponse,
DevicePodListResponse,
DevicePodStatusResponse,
LiveBuildsPayload,
LiveProbePayload,
SkillsResponse,
SkillFileResponse,
@@ -195,6 +196,7 @@ export const api = {
}),
logout: (): Promise<ApiResult<{ ok?: boolean }>> => fetchJson("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
liveBuilds: (): Promise<ApiResult<LiveBuildsPayload>> => fetchJson("/v1/live-builds", { timeoutMs: 12000, timeoutName: "live builds" }),
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }),
+5 -3
View File
@@ -12,6 +12,7 @@ import type {
DevicePodItem,
DevicePodListResponse,
DevicePodStatusResponse,
LiveBuildsPayload,
LiveSurface,
ProviderProfile,
SessionTab,
@@ -158,19 +159,20 @@ export function useWorkbenchStore(enabled: boolean): WorkbenchStore {
const refreshLive = useCallback(async (devicePodId?: string) => {
const requestedDevicePodId = devicePodId ?? state.selectedDevicePodId;
const [healthLive, health, restIndex, adapter, devicePods] = await Promise.all([
const [healthLive, health, restIndex, adapter, devicePods, liveBuilds] = await Promise.all([
api.healthLive(),
api.health(),
api.restIndex(),
api.adapter(),
api.devicePods()
api.devicePods(),
api.liveBuilds()
]);
const visibleDevicePods = devicePodsFromResult(devicePods);
const selected = selectVisibleDevicePodId(devicePods.data, visibleDevicePods, requestedDevicePodId);
const [devicePodStatus, devicePodEvents] = selected
? await Promise.all([api.devicePodStatus(selected), api.devicePodEvents(selected)])
: [null, null] satisfies [ApiResult<DevicePodStatusResponse> | null, ApiResult<DevicePodEventsResponse> | null];
const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, loadedAt: new Date().toISOString() };
const live: LiveSurface = { healthLive, health, restIndex, adapter, devicePods, devicePodStatus, devicePodEvents, liveBuilds, loadedAt: new Date().toISOString() };
dispatch({ type: "live:set", live, availability: availabilityFromLive(live), selectedDevicePodId: selected });
}, [state.selectedDevicePodId]);
+33 -4
View File
@@ -45,8 +45,15 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.workspace-topbar { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid var(--line); background: #fbfcf8; }
.workspace-topbar-button { min-height: 26px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 3px 8px; font-size: 12px; font-weight: 800; }
.workspace-topbar-button:disabled { opacity: 0.35; }
.workspace-topbar-status { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 800; }
.workspace-topbar-status { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--muted); font-size: 12px; font-weight: 800; overflow: hidden; }
.workspace-route-label { flex: 0 0 auto; white-space: nowrap; }
.workspace-topbar-pill { display: inline-flex; min-height: 22px; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 800; }
.topbar-status-tools { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 6px; overflow: hidden; }
.status-chip-button { max-width: min(38vw, 480px); min-height: 26px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); padding: 2px 7px; font-size: 12px; font-weight: 800; overflow: hidden; white-space: nowrap; }
.status-chip-button:hover, .status-chip-button:focus-visible { border-color: var(--accent); outline: 0; }
.status-chip-button .state-tag { min-height: 18px; padding: 1px 6px; font-size: 11px; white-space: nowrap; }
.status-chip-label { flex: 0 0 auto; }
.status-chip-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }
.workspace-body { min-width: 0; min-height: 0; display: grid; grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); grid-template-rows: minmax(0, 1fr); overflow: hidden; }
.workbench-shell.is-left-sidebar-collapsed .workspace-body { grid-template-columns: 0 0 minmax(420px, 1fr) var(--resize-handle-width) var(--right-sidebar-width); }
.workbench-shell.is-right-sidebar-collapsed .workspace-body { grid-template-columns: var(--session-sidebar-width) var(--resize-handle-width) minmax(420px, 1fr) 0 0; }
@@ -131,8 +138,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.message-runtime-summary { color: var(--muted); font-size: 12px; }
.message-session-summary { color: var(--muted); font-size: 12px; }
.probe-card { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
.probe-card-compact { min-width: 0; display: block; padding: 0; border-radius: 0; background: transparent; }
.probe-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.probe-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.probe-list-dialog { max-height: min(56vh, 560px); overflow: auto; padding-right: 2px; }
.probe-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.probe-row-head { display: flex; align-items: center; gap: 8px; }
.probe-tone { font-size: 12px; }
@@ -144,8 +153,17 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.probe-row-time { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.probe-row-detail { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.live-build-summary { display: grid; gap: 8px; padding: 10px 12px; border-radius: 8px; background: #fbfcf8; }
.live-build-summary-compact { min-width: 0; display: block; padding: 0; border-radius: 0; background: transparent; }
.live-build-details { min-width: 0; display: block; }
.live-build-details > summary { min-width: 0; display: block; list-style: none; }
.live-build-details > summary::-webkit-details-marker { display: none; }
.live-build-summary-line { cursor: default; }
.live-build-summary-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.live-build-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.live-build-list[hidden] { display: none; }
.live-build-list-dialog { max-height: min(58vh, 620px); overflow: auto; padding-right: 2px; }
.live-build-dialog-summary { display: grid; gap: 4px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: #fbfcf8; }
.live-build-dialog-summary p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
.live-build-row { display: grid; gap: 4px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
.live-build-row-head { display: flex; align-items: center; gap: 8px; }
.live-build-tone { font-size: 12px; }
@@ -153,6 +171,12 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.live-build-time { margin-left: auto; color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.live-build-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.live-build-reason { margin: 0; font-size: 12px; color: var(--bad); }
.workbench-dialog-layer { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; padding: 16px; background: rgba(15, 22, 20, 0.42); }
.workbench-dialog { width: min(760px, 94vw); max-height: min(86vh, 820px); display: grid; grid-template-rows: auto minmax(0, 1fr); border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); box-shadow: 0 24px 60px rgba(15, 22, 20, 0.34); overflow: hidden; }
.workbench-dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fbfcf8; }
.workbench-dialog-head h2 { margin: 0; font-size: 16px; }
.workbench-dialog-close { width: 30px; height: 30px; display: inline-grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--ink); font-size: 20px; line-height: 1; }
.workbench-dialog-content { min-height: 0; display: grid; gap: 10px; padding: 12px 14px; overflow: auto; }
.gate-raw { margin-top: 12px; }
.gate-raw pre { margin: 4px 0 0; max-height: 240px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
.gate-table tbody tr[data-status-key="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
@@ -223,7 +247,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.command-drafts-toggle { white-space: nowrap; }
.right-sidebar { min-width: 0; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr); gap: 0; padding: 0; background: #fbfcf8; border-left: 1px solid var(--line); overflow: hidden; }
.right-sidebar-content { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto auto auto auto minmax(0, 1fr); gap: 10px; padding: 14px; overflow-y: hidden; }
.right-sidebar-content { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 10px; padding: 14px; overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
.right-sidebar-content > * { flex: 0 0 auto; min-width: 0; }
.is-right-sidebar-collapsed .right-sidebar-content { display: none; }
.is-right-sidebar-collapsed .right-sidebar-resize { display: none; }
.is-right-sidebar-collapsed .right-sidebar { display: none; }
@@ -237,8 +262,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.device-pod-meta div { border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; }
.device-pod-meta dt { font-weight: 800; }
.device-pod-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
.device-pod-workspace { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 10px; overflow: hidden; }
.device-event-panel { min-height: 0; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
.device-pod-workspace { min-height: 360px; display: grid; grid-template-rows: auto minmax(220px, 1fr); gap: 10px; overflow: visible; }
.device-event-panel { min-height: 240px; display: grid; grid-template-rows: auto minmax(132px, 1fr); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: #fff; }
.device-event-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--line); font-weight: 800; }
.device-event-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 10px; background: #17201c; color: #e9f5e8; }
#device-event-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
@@ -294,6 +319,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.conversation-panel { padding: 10px; }
.conversation-panel .panel-title-actions { width: 100%; justify-content: flex-start; }
.code-agent-debug-summary .code-agent-summary-capability { display: none; }
.workspace-route-label, .workspace-topbar-pill { display: none; }
.topbar-status-tools { justify-content: flex-start; overflow-x: auto; }
.status-chip-button { max-width: 44vw; }
.status-chip-meta { display: none; }
.workspace-topbar-button { max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.command-bar { grid-template-columns: minmax(0, 1fr) auto auto; grid-template-areas: "profile drafts drafts" "input input input" "send send clear"; align-items: stretch; }
.command-bar .agent-timeout-control { display: none; }
+4
View File
@@ -1,3 +1,6 @@
import type { LiveBuildsPayload } from "./live-builds";
export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds";
export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
export type RouteId = "workspace" | "skills" | "gate" | "help" | "settings";
export type AuthState = "checking" | "login" | "authenticated";
@@ -43,6 +46,7 @@ export interface LiveSurface {
devicePods: ApiResult<DevicePodListResponse>;
devicePodStatus: ApiResult<DevicePodStatusResponse> | null;
devicePodEvents: ApiResult<DevicePodEventsResponse> | null;
liveBuilds: ApiResult<LiveBuildsPayload>;
loadedAt: string;
}
@@ -0,0 +1,48 @@
export interface LiveBuildsPayload {
kind?: string;
generatedAt?: string;
source?: Record<string, unknown> | null;
latest?: LiveBuildRecord | null;
counts?: Record<string, unknown> | null;
services?: LiveBuildRecord[];
[key: string]: unknown;
}
export interface LiveBuildRecord {
serviceId?: string;
name?: string;
kind?: string;
status?: string;
reason?: string | null;
healthUrl?: string | null;
httpStatus?: number | null;
desiredState?: Record<string, unknown> | null;
build?: LiveBuildBuild | null;
image?: LiveBuildImage | null;
commit?: LiveBuildCommit | null;
runtime?: Record<string, unknown> | null;
revision?: string | null;
[key: string]: unknown;
}
export interface LiveBuildBuild {
createdAt?: string | null;
source?: string | null;
metadataSource?: string | null;
unavailableReason?: string | null;
liveMetadataMatch?: Record<string, unknown> | null;
[key: string]: unknown;
}
export interface LiveBuildImage {
tag?: string | null;
digest?: string | null;
repository?: string | null;
[key: string]: unknown;
}
export interface LiveBuildCommit {
id?: string | null;
short?: string | null;
[key: string]: unknown;
}