Skip to content

Commit 7e59717

Browse files
authored
fix: harden mobile and partial-truth command tower (#162)
* fix: harden mobile and partial-truth command tower * chore: retrigger hosted gates
1 parent 8312be0 commit 7e59717

5 files changed

Lines changed: 207 additions & 26 deletions

File tree

apps/dashboard/app/command-tower/page.tsx

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -111,19 +111,6 @@ export async function CommandTowerHomeSection({ locale }: { locale: UiLocale })
111111
]}
112112
/>
113113
) : null}
114-
{warning && hasLiveData ? (
115-
<ControlPlaneStatusCallout
116-
title={commandTowerCopy.partialTitle}
117-
summary={warning}
118-
nextAction={commandTowerCopy.partialNextAction}
119-
tone="warning"
120-
badgeLabel={commandTowerCopy.partialBadge}
121-
actions={[
122-
{ href: "/runs", label: commandTowerCopy.actions.openRuns },
123-
{ href: "/workflows", label: commandTowerCopy.actions.openWorkflowCases },
124-
]}
125-
/>
126-
) : null}
127114
</>
128115
);
129116
}
@@ -139,13 +126,16 @@ export function CommandTowerHomeSectionFallback({ locale }: { locale: UiLocale }
139126

140127
export function CommandTowerPageIntro({
141128
locale,
142-
recoverySummary,
129+
mode = "live",
130+
summary,
143131
}: {
144132
locale: UiLocale;
145-
recoverySummary?: string;
133+
mode?: "live" | "partial" | "recovery";
134+
summary?: string;
146135
}) {
147136
const commandTowerCopy = getUiCopy(locale).dashboard.commandTowerPage;
148-
const recoveryMode = Boolean(recoverySummary);
137+
const recoveryMode = mode === "recovery";
138+
const partialMode = mode === "partial";
149139
const towerActions = recoveryMode
150140
? locale === "zh-CN"
151141
? [
@@ -156,6 +146,16 @@ export function CommandTowerPageIntro({
156146
{ href: "/command-tower", label: "Reload Command Tower", variant: "default" as const },
157147
{ href: "/pm", label: "Start from PM", variant: "secondary" as const },
158148
]
149+
: partialMode
150+
? locale === "zh-CN"
151+
? [
152+
{ href: "/command-tower", label: "重载指挥塔", variant: "default" as const },
153+
{ href: "/runs", label: commandTowerCopy.actions.viewRuns, variant: "secondary" as const },
154+
]
155+
: [
156+
{ href: "/command-tower", label: "Reload Command Tower", variant: "default" as const },
157+
{ href: "/runs", label: commandTowerCopy.actions.viewRuns, variant: "secondary" as const },
158+
]
159159
: locale === "zh-CN"
160160
? [
161161
{ href: "/events", label: "打开风险事件", variant: "warning" as const },
@@ -176,6 +176,10 @@ export function CommandTowerPageIntro({
176176
? locale === "zh-CN"
177177
? "恢复模式 / 当前主面不可用"
178178
: "Recovery mode / live surface unavailable"
179+
: partialMode
180+
? locale === "zh-CN"
181+
? "部分真相 / live 面当前降级"
182+
: "Partial truth / live surface degraded"
179183
: locale === "zh-CN"
180184
? "L0 驾驶舱 / 实时控制桌"
181185
: "L0 cockpit / live control desk"}
@@ -188,11 +192,15 @@ export function CommandTowerPageIntro({
188192
? locale === "zh-CN"
189193
? "指挥塔当前拿不到 live 总览。先确认只读真相,再走一条恢复路径。"
190194
: "Command Tower cannot read the live overview right now. Verify the read-only truth first, then take one recovery path."
195+
: partialMode
196+
? commandTowerCopy.partialNextAction
191197
: commandTowerCopy.srSubtitle}
192198
</p>
193199
<p className="cell-sub mono muted">
194200
{recoveryMode
195-
? recoverySummary
201+
? summary
202+
: partialMode
203+
? summary
196204
: locale === "zh-CN"
197205
? "这一页应该先告诉你:现在发生什么、哪条线危险、下一步该去哪个真相入口。"
198206
: "This page should answer three questions first: what is happening now, which lane is risky, and which truth surface to open next."}
@@ -212,15 +220,23 @@ export function CommandTowerPageIntro({
212220
? locale === "zh-CN"
213221
? "恢复判断"
214222
: "Recovery judgment"
223+
: partialMode
224+
? locale === "zh-CN"
225+
? "部分真相判断"
226+
: "Partial-truth judgment"
215227
: locale === "zh-CN"
216228
? "值班判断"
217229
: "Operator judgment"}
218230
</span>
219-
<Badge variant={recoveryMode ? "warning" : "running"}>
231+
<Badge variant={recoveryMode || partialMode ? "warning" : "running"}>
220232
{recoveryMode
221233
? locale === "zh-CN"
222234
? "先恢复主面"
223235
: "Restore the surface first"
236+
: partialMode
237+
? locale === "zh-CN"
238+
? commandTowerCopy.partialBadge
239+
: commandTowerCopy.partialBadge
224240
: locale === "zh-CN"
225241
? "先看 live"
226242
: "Live first"}
@@ -245,6 +261,24 @@ export function CommandTowerPageIntro({
245261
<p>{locale === "zh-CN" ? "把恢复动作收成一条主路径,而不是继续分散到多个正常驾驶舱动作。 " : "Keep recovery on one main path instead of splitting attention across normal cockpit actions."}</p>
246262
</div>
247263
</>
264+
) : partialMode ? (
265+
<>
266+
<div className="home-briefing-signal">
267+
<span className="cell-sub mono muted">{locale === "zh-CN" ? "当前状态" : "Current state"}</span>
268+
<strong>{commandTowerCopy.partialTitle}</strong>
269+
<p>{locale === "zh-CN" ? "你现在看到的是部分可读的值班面,不是完整 live cockpit。首屏要先承认降级,再继续分诊。" : "You are looking at a partially readable operator surface, not a full live cockpit. The first screen should acknowledge degradation before continuing triage."}</p>
270+
</div>
271+
<div className="home-briefing-signal">
272+
<span className="cell-sub mono muted">{locale === "zh-CN" ? "仍然成立的真相" : "What still holds"}</span>
273+
<strong>{locale === "zh-CN" ? "可见 board 只算部分快照" : "The visible board only counts as a partial snapshot"}</strong>
274+
<p>{commandTowerCopy.partialNextAction}</p>
275+
</div>
276+
<div className="home-briefing-signal">
277+
<span className="cell-sub mono muted">{locale === "zh-CN" ? "下一步" : "What to do next"}</span>
278+
<strong>{locale === "zh-CN" ? "先重载,再核对运行记录" : "Reload first, then verify runs"}</strong>
279+
<p>{locale === "zh-CN" ? "把动作收成一条恢复路径和一条只读核对路径,不要继续按正常 live cockpit 分散注意力。" : "Keep the first screen to one recovery path and one read-only verification path instead of scattering attention across normal live-cockpit actions."}</p>
280+
</div>
281+
</>
248282
) : (
249283
<>
250284
<div className="home-briefing-signal">
@@ -275,12 +309,12 @@ export default async function CommandTowerPage() {
275309
const cookieStore = await cookies();
276310
const locale = normalizeUiLocale(cookieStore.get(UI_LOCALE_STORAGE_KEY)?.value);
277311
const { warning, hasLiveData } = await loadCommandTowerHomeState(locale);
278-
const recoverySummary =
279-
warning && !hasLiveData ? warning : undefined;
312+
const introMode = warning ? (hasLiveData ? "partial" : "recovery") : "live";
313+
const introSummary = warning || undefined;
280314
return (
281315
<main className="grid" aria-labelledby="command-tower-page-title" aria-describedby="command-tower-page-subtitle">
282-
<CommandTowerPageIntro locale={locale} recoverySummary={recoverySummary} />
283-
{recoverySummary ? null : (
316+
<CommandTowerPageIntro locale={locale} mode={introMode} summary={introSummary} />
317+
{introMode === "recovery" ? null : (
284318
<Suspense fallback={<CommandTowerHomeSectionFallback locale={locale} />}>
285319
<CommandTowerHomeSection locale={locale} />
286320
</Suspense>

apps/dashboard/app/globals.feature.part04.css

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,7 @@ body {
14781478
}
14791479

14801480
.app-shell--landing .home-briefing-shell {
1481-
grid-template-columns: minmax(0, 1.18fr) 420px;
1481+
grid-template-columns: minmax(0, 1fr);
14821482
gap: var(--space-5);
14831483
}
14841484

@@ -1489,6 +1489,7 @@ body {
14891489
radial-gradient(circle at top left, color-mix(in srgb, var(--color-primary) 10%, transparent), transparent 44%);
14901490
box-shadow: none;
14911491
padding: var(--space-3) 0 var(--space-4);
1492+
max-width: 72rem;
14921493
}
14931494

14941495
.app-shell--landing .home-briefing-copy .page-title {
@@ -1512,6 +1513,23 @@ body {
15121513
min-height: 100%;
15131514
}
15141515

1516+
@media (max-width: 640px) {
1517+
.app-shell--landing .home-briefing-copy {
1518+
max-width: none;
1519+
padding: var(--space-2) 0 var(--space-3);
1520+
}
1521+
1522+
.app-shell--landing .home-briefing-copy .page-title {
1523+
font-size: clamp(2.25rem, 12vw, 3.25rem);
1524+
max-width: 9ch;
1525+
}
1526+
1527+
.app-shell--landing .home-briefing-copy .page-subtitle {
1528+
max-width: 30ch;
1529+
font-size: 16px;
1530+
}
1531+
}
1532+
15151533
.ui-card,
15161534
.metric-card,
15171535
.quick-card,

apps/dashboard/app/globals.responsive.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,17 @@
9393
grid-template-columns: 1fr;
9494
}
9595

96+
.app-main {
97+
order: 1;
98+
}
99+
96100
.sidebar {
101+
order: 2;
97102
position: static;
98103
height: auto;
99104
border-right: 0;
100-
border-bottom: 1px solid var(--color-border);
105+
border-top: 1px solid var(--color-border);
106+
border-bottom: 0;
101107
flex-direction: row;
102108
flex-wrap: wrap;
103109
padding: var(--space-4);

apps/dashboard/tests/command_tower_page_render.test.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,66 @@ describe("command tower page render", () => {
150150

151151
expect(screen.getByRole("heading", { name: "指挥塔" })).toBeInTheDocument();
152152
expect(screen.getByText("L0 驾驶舱 / 实时控制桌")).toBeInTheDocument();
153-
expect(screen.getByText("这一页应该先告诉你:现在发生什么、哪条线危险、下一步该去哪个真相入口。")).toBeInTheDocument();
154153
expect(screen.getByRole("status")).toHaveTextContent("正在加载指挥塔实时总览...");
155-
expect(screen.getByTestId("ct-callout")).toHaveTextContent("指挥塔当前只提供部分真相");
154+
expect(screen.queryByTestId("ct-callout")).toBeNull();
156155
expect(screen.getByTestId("ct-live-client")).toHaveTextContent("overview:0 sessions:1");
157156
});
158157

158+
it("switches the page intro into partial-truth mode when warning data still has live context", async () => {
159+
mockFetchCommandTowerOverview.mockRejectedValueOnce(new Error("overview down"));
160+
mockFetchPmSessions.mockResolvedValueOnce([
161+
{
162+
pm_session_id: "pm-partial",
163+
status: "active",
164+
run_count: 1,
165+
running_runs: 1,
166+
failed_runs: 0,
167+
success_runs: 0,
168+
blocked_runs: 0,
169+
},
170+
] as never);
171+
172+
render(await CommandTowerPage());
173+
174+
expect(screen.getByText("Partial truth / live surface degraded")).toBeInTheDocument();
175+
expect(screen.getByText("Command Tower is running with partial truth")).toBeInTheDocument();
176+
expect(screen.getByText("Partial context")).toBeInTheDocument();
177+
expect(screen.getByRole("link", { name: "Reload Command Tower" })).toHaveAttribute("href", "/command-tower");
178+
expect(screen.getByRole("link", { name: "View runs" })).toHaveAttribute("href", "/runs");
179+
expect(screen.queryByTestId("ct-callout")).toBeNull();
180+
expect(screen.getByRole("status")).toHaveTextContent("Loading Command Tower live overview...");
181+
});
182+
183+
it("switches the page intro into zh-CN partial-truth mode when warning data still has live context", async () => {
184+
mockCookies.mockResolvedValue({
185+
get: (name: string) => (name === "openvibecoding.ui.locale" ? { value: "zh-CN" } : undefined),
186+
toString: () => "openvibecoding.ui.locale=zh-CN",
187+
});
188+
mockFetchCommandTowerOverview.mockRejectedValueOnce(new Error("总览失败"));
189+
mockFetchPmSessions.mockResolvedValueOnce([
190+
{
191+
pm_session_id: "pm-partial-zh",
192+
status: "active",
193+
run_count: 1,
194+
running_runs: 1,
195+
failed_runs: 0,
196+
success_runs: 0,
197+
blocked_runs: 0,
198+
},
199+
] as never);
200+
201+
render(await CommandTowerPage());
202+
203+
expect(screen.getByText("部分真相 / live 面当前降级")).toBeInTheDocument();
204+
expect(screen.getByText("指挥塔当前只提供部分真相")).toBeInTheDocument();
205+
expect(screen.getByText("上下文不完整")).toBeInTheDocument();
206+
expect(screen.getByRole("status")).toHaveTextContent("正在加载指挥塔实时总览...");
207+
expect(screen.getByRole("link", { name: "重载指挥塔" })).toHaveAttribute("href", "/command-tower");
208+
expect(screen.getByRole("link", { name: "查看运行记录" })).toHaveAttribute("href", "/runs");
209+
expect(screen.queryByTestId("ct-callout")).toBeNull();
210+
expect(screen.getByRole("status")).toHaveTextContent("正在加载指挥塔实时总览...");
211+
});
212+
159213
it("switches the page intro into recovery mode when live data is unavailable", async () => {
160214
mockFetchCommandTowerOverview.mockRejectedValueOnce(new Error("overview down"));
161215
mockFetchPmSessions.mockRejectedValueOnce(new Error("sessions down"));
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
import { describe, expect, it } from "vitest";
6+
7+
function readCssBundle(entryPath: string, visited: Set<string> = new Set()): string {
8+
if (!existsSync(entryPath) || visited.has(entryPath)) {
9+
return "";
10+
}
11+
visited.add(entryPath);
12+
const css = readFileSync(entryPath, "utf8");
13+
const imports = [...css.matchAll(/@import\s+["'](.+?)["'];/g)];
14+
let bundledCss = css;
15+
for (const match of imports) {
16+
const importTarget = match[1];
17+
if (!importTarget.endsWith(".css")) {
18+
continue;
19+
}
20+
bundledCss += `\n${readCssBundle(resolve(dirname(entryPath), importTarget), visited)}`;
21+
}
22+
return bundledCss;
23+
}
24+
25+
function loadDashboardCss(): string {
26+
const cssPath = (() => {
27+
try {
28+
return fileURLToPath(new URL("../app/globals.css", import.meta.url));
29+
} catch {
30+
return resolve(process.cwd(), "app/globals.css");
31+
}
32+
})();
33+
return readCssBundle(cssPath);
34+
}
35+
36+
describe("dashboard landing home layout contract", () => {
37+
it("keeps the landing briefing shell aligned with the single-column hero structure", () => {
38+
const css = loadDashboardCss();
39+
40+
expect(css).toMatch(
41+
/\.app-shell--landing\s+\.home-briefing-shell\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1fr\);/m
42+
);
43+
expect(css).not.toMatch(
44+
/\.app-shell--landing\s+\.home-briefing-shell\s*\{\s*grid-template-columns:\s*minmax\(0,\s*1\.18fr\)\s*420px;/m
45+
);
46+
});
47+
48+
it("keeps the landing hero title on a mobile-specific scale instead of inheriting the oversized desktop clamp", () => {
49+
const css = loadDashboardCss();
50+
51+
expect(css).toMatch(
52+
/@media\s*\(max-width:\s*640px\)\s*\{[\s\S]*?\.app-shell--landing\s+\.home-briefing-copy\s+\.page-title\s*\{\s*font-size:\s*clamp\(2\.25rem,\s*12vw,\s*3\.25rem\);/m
53+
);
54+
expect(css).toMatch(
55+
/@media\s*\(max-width:\s*640px\)\s*\{[\s\S]*?\.app-shell--landing\s+\.home-briefing-copy\s+\.page-subtitle\s*\{\s*max-width:\s*30ch;\s*font-size:\s*16px;/m
56+
);
57+
});
58+
59+
it("keeps the dashboard shell product-first on mobile by placing the app main content before the sidebar chrome", () => {
60+
const css = loadDashboardCss();
61+
62+
expect(css).toMatch(
63+
/@media\s*\(max-width:\s*920px\)\s*\{[\s\S]*?\.app-main\s*\{\s*order:\s*1;\s*\}/m
64+
);
65+
expect(css).toMatch(
66+
/@media\s*\(max-width:\s*920px\)\s*\{[\s\S]*?\.sidebar\s*\{[\s\S]*?order:\s*2;[\s\S]*?border-top:\s*1px solid var\(--color-border\);[\s\S]*?border-bottom:\s*0;/m
67+
);
68+
});
69+
});

0 commit comments

Comments
 (0)