Skip to content

Commit bb43f74

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(console): Build Doctor drawer — self-serve "what actually landed?" for AI builds (#2020)
Adds a Build Doctor button (build-agent conversations only) to the AI build page header. It opens a right-side drawer that calls the admin build-debug endpoint (objectstack-ai/cloud) and renders the reconciliation: what the agent CLAIMED vs what is LIVE in sys_metadata. The chat renders the agent's self-report ("已搭好"), never what actually landed — so a build that silently dropped a change (a confirm card no turn applied) reads as success. The drawer surfaces exactly that: - PROPOSED-BUT-ORPHANED: a changes_proposed card that evaporated. - CLAIMED-BUT-MISSING: said applied, isn't live. plus de-noised verify_build (platform sys_*/cloud_*/ai_* hidden), pending actions, and a collapsed timeline. Read-only; same-origin cookie auth, no DB credentials — the diagnosis a founder used to do by hand-querying two databases now happens in the browser. Distinct from useReconcileOnError (ADR-0013 D2 stream-failure recovery): this reconciles the BUILD against live metadata, not a transport drop. - buildDebugApi.ts: typed client for GET /conversations/:id/debug. - BuildDebugDrawer.tsx: the Sheet drawer + rendering. - AiChatPage: Build Doctor button (showDebug=isBuildAgent) + drawer wiring. type-check clean; app-shell suite green (929) incl. 2 new drawer tests (orphaned render + endpoint call). Needs the cloud service-ai debug endpoint deployed; absent it the drawer shows an empty state. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf2176d commit bb43f74

4 files changed

Lines changed: 440 additions & 1 deletion

File tree

packages/app-shell/src/console/ai/AiChatPage.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
EmptyDescription,
3737
cn,
3838
} from '@object-ui/components';
39-
import { PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
39+
import { Bug, PanelLeft, PanelLeftClose, PanelLeftOpen, Share2 } from 'lucide-react';
4040
import {
4141
ChatbotEnhanced,
4242
useAgents,
@@ -76,6 +76,7 @@ import {
7676
import { useReconcileOnError } from '../../hooks/useReconcileOnError';
7777
import { ConversationsSidebar } from './ConversationsSidebar';
7878
import { LiveCanvas } from './LiveCanvas';
79+
import { BuildDebugDrawer } from './BuildDebugDrawer';
7980

8081
const DEFAULT_AI_PATH = '/api/v1/ai';
8182

@@ -633,6 +634,7 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
633634
const [refreshKey, setRefreshKey] = useState(0);
634635
const [titleHints, setTitleHints] = useState<Record<string, string>>({});
635636
const [shareOpen, setShareOpen] = useState(false);
637+
const [debugOpen, setDebugOpen] = useState(false);
636638
const [mobileChatsOpen, setMobileChatsOpen] = useState(false);
637639
const {
638640
collapsed: chatsCollapsed,
@@ -833,6 +835,14 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
833835
publicBaseUrl={publicShareBase}
834836
/>
835837
)}
838+
{conversationId && isBuildAgent(activeAgent) && (
839+
<BuildDebugDrawer
840+
apiBase={apiBase}
841+
conversationId={conversationId}
842+
open={debugOpen}
843+
onOpenChange={setDebugOpen}
844+
/>
845+
)}
836846
<div className="flex min-h-0 flex-1 w-full bg-muted/20">
837847
{!chatsCollapsed && (
838848
<ConversationsSidebar
@@ -858,6 +868,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
858868
initialMessages={initialMessages}
859869
onSent={handleSent}
860870
onShare={() => setShareOpen(true)}
871+
onDebug={() => setDebugOpen(true)}
872+
showDebug={isBuildAgent(activeAgent)}
861873
onCanvasOpenChange={handleCanvasOpenChange}
862874
/>
863875
</main>
@@ -932,6 +944,10 @@ interface ChatPaneProps {
932944
initialMessages: HydratedUIMessage[];
933945
onSent: (firstUserMessage?: string) => void;
934946
onShare: () => void;
947+
/** Opens the Build Doctor drawer (build agent only). */
948+
onDebug?: () => void;
949+
/** Show the Build Doctor button — true only for build-agent conversations. */
950+
showDebug?: boolean;
935951
/** Reports the Live Canvas preview opening/closing so the page can auto-tuck the chats list. */
936952
onCanvasOpenChange?: (open: boolean) => void;
937953
}
@@ -948,6 +964,8 @@ function ChatPane({
948964
initialMessages,
949965
onSent,
950966
onShare,
967+
onDebug,
968+
showDebug,
951969
onCanvasOpenChange,
952970
}: ChatPaneProps) {
953971
const { t } = useObjectTranslation();
@@ -1141,6 +1159,20 @@ function ChatPane({
11411159
)}
11421160
</div>
11431161
<div className="flex shrink-0 items-center gap-1">
1162+
{showDebug && onDebug ? (
1163+
<Button
1164+
variant="ghost"
1165+
size="icon"
1166+
className="h-7 w-7 text-muted-foreground hover:text-foreground"
1167+
onClick={onDebug}
1168+
disabled={!conversationId}
1169+
aria-label="Build Doctor"
1170+
data-testid="ai-chat-debug-button"
1171+
title={conversationId ? 'Build Doctor — what actually landed?' : 'Send a message first'}
1172+
>
1173+
<Bug className="h-3.5 w-3.5" />
1174+
</Button>
1175+
) : null}
11441176
<Button
11451177
variant="ghost"
11461178
size="icon"
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* BuildDebugDrawer — self-serve "what actually landed?" panel for a build
5+
* conversation. Opens a right-side sheet, calls the admin build-debug endpoint
6+
* (see buildDebugApi.ts), and renders the reconciliation: agent-CLAIMED vs LIVE
7+
* `sys_metadata`. The headline is the verdict + the two failure modes the chat
8+
* can't show — PROPOSED-BUT-ORPHANED (a confirm card no turn applied) and
9+
* CLAIMED-BUT-MISSING (said applied, isn't live). Read-only; no DB credentials.
10+
*
11+
* Distinct from `useReconcileOnError` (ADR-0013 D2 stream-failure recovery) —
12+
* this reconciles the BUILD against live metadata, not a transport drop.
13+
*/
14+
15+
import React, { useEffect, useState } from 'react';
16+
import {
17+
Sheet,
18+
SheetContent,
19+
SheetHeader,
20+
SheetTitle,
21+
SheetDescription,
22+
} from '@object-ui/components';
23+
import { Bug, CheckCircle2, AlertTriangle, XCircle, Loader2, CircleSlash } from 'lucide-react';
24+
import { fetchBuildDebug, type BuildDebugReport, type MutationFinding } from './buildDebugApi';
25+
26+
interface BuildDebugDrawerProps {
27+
apiBase: string;
28+
conversationId?: string;
29+
open: boolean;
30+
onOpenChange: (open: boolean) => void;
31+
}
32+
33+
export function BuildDebugDrawer({ apiBase, conversationId, open, onOpenChange }: BuildDebugDrawerProps) {
34+
const [report, setReport] = useState<BuildDebugReport | null>(null);
35+
const [loading, setLoading] = useState(false);
36+
const [error, setError] = useState<string | null>(null);
37+
38+
useEffect(() => {
39+
if (!open || !conversationId) return;
40+
let cancelled = false;
41+
setLoading(true);
42+
setError(null);
43+
setReport(null);
44+
fetchBuildDebug(apiBase, conversationId)
45+
.then((r) => {
46+
if (cancelled) return;
47+
if (!r) setError('Not available — the conversation was not found or you are not authorized.');
48+
else setReport(r);
49+
})
50+
.catch((e: unknown) => {
51+
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
52+
})
53+
.finally(() => {
54+
if (!cancelled) setLoading(false);
55+
});
56+
return () => {
57+
cancelled = true;
58+
};
59+
}, [open, conversationId, apiBase]);
60+
61+
const rec = report?.reconciliation;
62+
const problems = rec ? rec.orphaned.length + rec.missing.length + rec.errors.length : 0;
63+
64+
return (
65+
<Sheet open={open} onOpenChange={onOpenChange}>
66+
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-xl">
67+
<SheetHeader>
68+
<SheetTitle className="flex items-center gap-2">
69+
<Bug className="h-4 w-4" /> Build Doctor
70+
</SheetTitle>
71+
<SheetDescription>
72+
What the agent claimed vs what is actually live. Read-only diagnostic.
73+
</SheetDescription>
74+
</SheetHeader>
75+
76+
<div className="mt-4 space-y-4 text-sm">
77+
{loading && (
78+
<div className="flex items-center gap-2 text-muted-foreground">
79+
<Loader2 className="h-4 w-4 animate-spin" /> Reconciling…
80+
</div>
81+
)}
82+
{error && !loading && (
83+
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive">
84+
{error}
85+
</div>
86+
)}
87+
88+
{report && !loading && (
89+
<>
90+
{/* Summary line */}
91+
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
92+
<div className="font-medium text-foreground">{report.title ?? '(untitled)'}</div>
93+
<div className="mt-1">
94+
{report.summary.userTurns} turn(s) · {report.summary.messages} msgs ·{' '}
95+
{report.summary.totalTokens.toLocaleString()} tok ·{' '}
96+
{(report.summary.llmMs / 1000).toFixed(1)}s LLM
97+
{report.summary.models.length ? ` · ${report.summary.models.join(', ')}` : ''}
98+
</div>
99+
</div>
100+
101+
{/* Verdict */}
102+
{rec && (
103+
<div
104+
className={
105+
rec.ok
106+
? 'flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 p-3 text-emerald-700 dark:text-emerald-400'
107+
: 'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-destructive'
108+
}
109+
>
110+
{rec.ok ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
111+
<span className="font-medium">
112+
{rec.ok
113+
? `All ${rec.liveCount} attempted change(s) are live — nothing evaporated.`
114+
: `${problems} discrepancy(ies) — what the chat said doesn't match what's live.`}
115+
</span>
116+
</div>
117+
)}
118+
119+
{/* Orphaned — the headline failure */}
120+
{rec && rec.orphaned.length > 0 && (
121+
<FindingSection
122+
icon={<CircleSlash className="h-4 w-4 text-destructive" />}
123+
title="Proposed but never applied"
124+
hint="A confirm card the agent proposed but no later turn applied — the change silently evaporated."
125+
findings={rec.orphaned}
126+
tone="destructive"
127+
/>
128+
)}
129+
{rec && rec.missing.length > 0 && (
130+
<FindingSection
131+
icon={<AlertTriangle className="h-4 w-4 text-amber-600" />}
132+
title="Claimed but missing"
133+
hint="A tool result said it was applied, but the artifact isn't live in sys_metadata."
134+
findings={rec.missing}
135+
tone="amber"
136+
/>
137+
)}
138+
{rec && rec.errors.length > 0 && (
139+
<FindingSection
140+
icon={<XCircle className="h-4 w-4 text-destructive" />}
141+
title="Tool errors"
142+
hint="Tool calls that returned an error during the build."
143+
findings={rec.errors}
144+
tone="destructive"
145+
/>
146+
)}
147+
148+
{/* verify_build, de-noised */}
149+
{report.verify && (
150+
<div className="rounded-md border p-3 text-xs">
151+
<div className="font-medium text-foreground">Build check (verify_build)</div>
152+
<div className="mt-1 text-muted-foreground">
153+
Your app:{' '}
154+
{report.verify.userIssues.length === 0 ? (
155+
<span className="text-emerald-600 dark:text-emerald-400">0 issues</span>
156+
) : (
157+
<span className="text-destructive">{report.verify.userIssues.length} issue(s)</span>
158+
)}
159+
{report.verify.platformNoise > 0
160+
? ` · ${report.verify.platformNoise} platform sys_* finding(s) hidden`
161+
: ''}
162+
</div>
163+
{report.verify.userIssues.map((is, i) => (
164+
<div key={i} className="mt-1 text-destructive">
165+
[{is.severity}] {is.code} {is.artifact ? `${is.artifact.type}:${is.artifact.name}` : ''}
166+
</div>
167+
))}
168+
</div>
169+
)}
170+
171+
{/* Pending actions */}
172+
{report.pendingActions.length > 0 && (
173+
<div className="rounded-md border p-3 text-xs">
174+
<div className="font-medium text-foreground">Pending actions</div>
175+
{report.pendingActions.map((p, i) => (
176+
<div key={i} className="mt-1 text-muted-foreground">
177+
{p.tool ?? '?'} · {p.object ?? '-'} · <span className="font-mono">{p.status ?? '-'}</span>
178+
</div>
179+
))}
180+
</div>
181+
)}
182+
183+
{/* Timeline (collapsed) */}
184+
<details className="rounded-md border p-3 text-xs">
185+
<summary className="cursor-pointer font-medium text-foreground">
186+
Timeline ({report.timeline.length})
187+
</summary>
188+
<div className="mt-2 space-y-1 font-mono text-[11px] leading-relaxed">
189+
{report.timeline.map((e, i) => (
190+
<TimelineRow key={i} entry={e} />
191+
))}
192+
</div>
193+
</details>
194+
</>
195+
)}
196+
</div>
197+
</SheetContent>
198+
</Sheet>
199+
);
200+
}
201+
202+
function FindingSection({
203+
icon,
204+
title,
205+
hint,
206+
findings,
207+
tone,
208+
}: {
209+
icon: React.ReactNode;
210+
title: string;
211+
hint: string;
212+
findings: MutationFinding[];
213+
tone: 'destructive' | 'amber';
214+
}) {
215+
const border = tone === 'destructive' ? 'border-destructive/30' : 'border-amber-500/30';
216+
return (
217+
<div className={`rounded-md border ${border} p-3`}>
218+
<div className="flex items-center gap-2 font-medium text-foreground">
219+
{icon} {title} ({findings.length})
220+
</div>
221+
<div className="mt-1 text-xs text-muted-foreground">{hint}</div>
222+
<div className="mt-2 space-y-1">
223+
{findings.map((f, i) => (
224+
<div key={i} className="font-mono text-xs">
225+
{f.t ? `${f.t} · ` : ''}
226+
{f.tool}{f.artifact.type}:{f.artifact.name}
227+
<span className="text-muted-foreground"> ({f.status})</span>
228+
</div>
229+
))}
230+
</div>
231+
</div>
232+
);
233+
}
234+
235+
function TimelineRow({ entry }: { entry: BuildDebugReport['timeline'][number] }) {
236+
if (entry.kind === 'user') {
237+
return (
238+
<div>
239+
<span className="text-muted-foreground">{entry.t}</span> 👤 {entry.text}
240+
</div>
241+
);
242+
}
243+
if (entry.kind === 'assistant-text') {
244+
return (
245+
<div>
246+
<span className="text-muted-foreground">{entry.t}</span> 🤖 {entry.text}
247+
</div>
248+
);
249+
}
250+
if (entry.kind === 'assistant-calls') {
251+
return (
252+
<div>
253+
<span className="text-muted-foreground">{entry.t}</span> 🤖 →{' '}
254+
{entry.calls.map((c) => c.name).join(', ')}
255+
</div>
256+
);
257+
}
258+
return (
259+
<div className={entry.isError ? 'text-destructive' : ''}>
260+
<span className="text-muted-foreground">{entry.t}</span>{entry.name}
261+
{entry.status ? ` (${entry.status})` : ''}
262+
</div>
263+
);
264+
}

0 commit comments

Comments
 (0)