Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ VITE_GATEWAY_TOKEN=

# Mock 模式(设为 true 可不连接真实 Gateway 进行开发)
# VITE_MOCK=true

# --- 运营商引导配置(可选)---
# 运营商引导包 URL(用于从框架层加载 projection bootstrap 数据)
# 支持 HTTP/HTTPS URL 或本地相对路径
# VITE_PROJECTION_BOOTSTRAP_URL=https://example.com/projection-bootstrap.json
# VITE_PROJECTION_BOOTSTRAP_PATH=/bootstrap/projection-packet.json
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,36 @@ VITE_MOCK=true pnpm dev

This uses simulated Agent data for UI development.

In the current bootstrap, mock mode also loads the framework-generated projection packet sample and seeds the office scene plus the first-batch projection panels from that packet. This is still a mock/dev path only; it does not change the live Gateway contract.

### Operator-Facing Projection Bootstrap (Real Gateway + bootstrap feed)

A controlled non-mock/operator path is also available when you want Office to connect to the **real Gateway** while still seeding the first projection panels from a framework-generated packet.

1. Generate or copy a packet such as:
- `/home/ubuntu/projects/_department/work/openclaw-office-projection-feed-bootstrap-20260325/fixtures/openclaw_office_projection_packet_v0.json`
2. Serve it from the Office app origin, for example:

```bash
mkdir -p public/bootstrap
cp /path/to/openclaw_office_projection_packet_v0.json public/bootstrap/projection-packet.json
```

3. Start Office against the real Gateway and point the operator bootstrap to that URL:

```bash
VITE_GATEWAY_URL=ws://127.0.0.1:18789 \
VITE_GATEWAY_TOKEN=<your-token> \
VITE_PROJECTION_BOOTSTRAP_URL=/bootstrap/projection-packet.json \
pnpm dev
```

Behavior boundary:
- the real Gateway connection path remains intact
- bootstrap loading is optional and guarded by env vars
- this does **not** introduce a new live Gateway protocol message
- this supports operator-facing rollout progress, but does **not** by itself claim upstream merge

---

## Project Structure
Expand Down
21 changes: 20 additions & 1 deletion src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { AgentDetailPanel } from "@/components/panels/AgentDetailPanel";
import { EventTimeline } from "@/components/panels/EventTimeline";
import { MetricsPanel } from "@/components/panels/MetricsPanel";
import { ProjectionBootstrapPanel } from "@/components/panels/ProjectionBootstrapPanel";
import { SubAgentPanel } from "@/components/panels/SubAgentPanel";
import { CollapsibleSection } from "@/components/shared/CollapsibleSection";
import { SvgAvatar } from "@/components/shared/SvgAvatar";
Expand All @@ -17,6 +18,7 @@ export function Sidebar() {
const { t } = useTranslation("layout");
const agents = useOfficeStore((s) => s.agents);
const selectedAgentId = useOfficeStore((s) => s.selectedAgentId);
const projectionPanels = useOfficeStore((s) => s.projectionPanels);
const selectAgent = useOfficeStore((s) => s.selectAgent);
const collapsed = useOfficeStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useOfficeStore((s) => s.setSidebarCollapsed);
Expand Down Expand Up @@ -76,6 +78,7 @@ export function Sidebar() {
const subAgentsSection = getSection("subAgents");
const detailSection = getSection("detail");
const timelineSection = getSection("timeline");
const projectionSection = getSection("projectionBootstrap");

return (
<aside className="flex w-80 flex-col border-l border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
Expand Down Expand Up @@ -103,10 +106,26 @@ export function Sidebar() {
onHeightChange={(h) => setSectionHeight("metrics", h)}
minHeight={120}
maxHeight={400}
>
>
<MetricsPanel />
</CollapsibleSection>

{projectionPanels.length > 0 && (
<CollapsibleSection
id="projectionBootstrap"
title={t("sidebar.projectionBootstrap")}
collapsed={projectionSection.collapsed}
onToggle={() => toggleSection("projectionBootstrap")}
height={projectionSection.height}
onHeightChange={(h) => setSectionHeight("projectionBootstrap", h)}
minHeight={180}
maxHeight={520}
badge={projectionPanels.length}
>
<ProjectionBootstrapPanel />
</CollapsibleSection>
)}

{/* Agent list — primary section, takes remaining space */}
<CollapsibleSection
id="agents"
Expand Down
73 changes: 72 additions & 1 deletion src/components/layout/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen, fireEvent, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Sidebar } from "@/components/layout/Sidebar";
import i18n from "@/i18n/test-setup";
import { useOfficeStore } from "@/store/office-store";
Expand All @@ -22,6 +22,7 @@ describe("Sidebar", () => {
selectedAgentId: null,
sidebarCollapsed: false,
eventHistory: [],
projectionPanels: [],
globalMetrics: {
activeAgents: 0,
totalAgents: 0,
Expand All @@ -38,6 +39,28 @@ describe("Sidebar", () => {
setupAgents();
});

afterEach(() => {
useOfficeStore.setState({
agents: new Map(),
selectedAgentId: null,
sidebarCollapsed: false,
eventHistory: [],
projectionPanels: [],
globalMetrics: {
activeAgents: 0,
totalAgents: 0,
totalTokens: 0,
tokenRate: 0,
collaborationHeat: 0,
},
links: [],
connectionStatus: "disconnected",
connectionError: null,
runIdMap: new Map(),
sessionKeyMap: new Map(),
});
});

it("renders all agents", () => {
render(<MemoryRouter><Sidebar /></MemoryRouter>);
expect(screen.getByText("Coder")).toBeInTheDocument();
Expand Down Expand Up @@ -73,4 +96,52 @@ describe("Sidebar", () => {
expect(screen.getAllByText("Coder").length).toBeGreaterThan(0);
expect(screen.queryByText("Reviewer")).not.toBeInTheDocument();
});

it("renders projection bootstrap panels when seeded", () => {
useOfficeStore.getState().applyProjectionBootstrap({
schemaVersion: "openclaw-office-projection-packet.v0",
generatedAt: "2026-03-25T07:15:13Z",
sources: {},
officeProjection: {
mode: "framework-bootstrap/mock-seed-ready",
runtimeBoundary: {
contextEngineSlot: "legacy",
projectionContextEngineRolloutGate: "unchanged",
notes: [],
},
scene: {
agents: [],
links: [],
eventHistory: [],
globalMetrics: {
activeAgents: 0,
totalAgents: 0,
totalTokens: 0,
tokenRate: 0,
collaborationHeat: 0,
},
},
panels: {
decisionDrift: {
panelId: "decision-drift",
title: "Decision drift",
severity: "warn",
summary: { count: 1 },
items: [
{
kind: "deterministic-example",
title: "Pointer cache tries to override live board focus",
caseId: "live_projection_pointer_override",
},
],
},
},
},
});

render(<MemoryRouter><Sidebar /></MemoryRouter>);
expect(screen.getByText(t("layout:sidebar.projectionBootstrap"))).toBeInTheDocument();
expect(screen.getByText("Decision drift")).toBeInTheDocument();
expect(screen.getByText("Pointer cache tries to override live board focus")).toBeInTheDocument();
});
});
168 changes: 168 additions & 0 deletions src/components/panels/ProjectionBootstrapPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { useTranslation } from "react-i18next";
import type { ProjectionPanel, ProjectionPanelItem } from "@/gateway/types";
import { useOfficeStore } from "@/store/office-store";

export function ProjectionBootstrapPanel() {
const { t } = useTranslation("panels");
const panels = useOfficeStore((s) => s.projectionPanels);

if (panels.length === 0) {
return null;
}

return (
<div className="space-y-2 px-2 py-2">
{panels.map((panel) => (
<ProjectionPanelCard key={panel.panelId} panel={panel} t={t} />
))}
</div>
);
}

function ProjectionPanelCard({
panel,
t,
}: {
panel: ProjectionPanel;
t: (key: string, opts?: { count?: number }) => string;
}) {
const severityClass = severityClasses[panel.severity];
const summaryEntries = Object.entries(panel.summary);

return (
<article className="rounded-lg border border-gray-200 bg-white px-2 py-2 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between gap-2">
<h4 className="min-w-0 truncate text-xs font-semibold text-gray-800 dark:text-gray-100">
{panel.title}
</h4>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${severityClass}`}
>
{t(`projectionBootstrap.severities.${panel.severity}`)}
</span>
</div>

<div className="mt-1 flex flex-wrap gap-1">
{summaryEntries.map(([key, value]) => (
<span
key={key}
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
{key}: {String(value)}
</span>
))}
</div>

<div className="mt-2 space-y-1">
{panel.items.length > 0 ? (
panel.items.slice(0, 2).map((item) => (
<ProjectionPanelItemRow key={item.caseId ?? item.title} item={item} t={t} depth={0} />
))
) : (
<div className="rounded bg-gray-50 px-2 py-1 text-[10px] text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{panel.emptyState ?? t("projectionBootstrap.noItems")}
</div>
)}
{panel.items.length > 2 && (
<div className="pt-1 text-[10px] text-gray-400 dark:text-gray-500">
{t("projectionBootstrap.moreItems", { count: panel.items.length - 2 })}
</div>
)}
</div>
</article>
);
}

function ProjectionPanelItemRow({
item,
t,
depth,
}: {
item: ProjectionPanelItem;
t: (key: string, opts?: { count?: number }) => string;
depth: number;
}) {
const meta = buildItemMeta(item, t);
const detailTags = [
...normalizeTags(item.driftTypes),
...normalizeTags(item.detectedDriftTypes),
];

return (
<div className={`rounded border border-gray-100 bg-gray-50 px-2 py-1 dark:border-gray-800 dark:bg-gray-800/70 ${depth > 0 ? "ml-2" : ""}`}>
<div className="text-[11px] font-medium text-gray-700 dark:text-gray-200">{item.title}</div>
{meta.length > 0 && (
<div className="mt-0.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-gray-500 dark:text-gray-400">
{meta.map((line) => (
<span key={line}>{line}</span>
))}
</div>
)}
{detailTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{detailTags.map((tag) => (
<span
key={tag}
className="rounded-full bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-700 dark:bg-blue-950/60 dark:text-blue-300"
>
{tag}
</span>
))}
</div>
)}
{item.driftCounts && Object.keys(item.driftCounts).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{Object.entries(item.driftCounts).map(([key, value]) => (
<span
key={key}
className="rounded bg-orange-50 px-1.5 py-0.5 text-[10px] text-orange-700 dark:bg-orange-950/60 dark:text-orange-300"
>
{key}: {value}
</span>
))}
</div>
)}
{item.chainLabels && item.chainLabels.length > 0 && (
<div className="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{item.chainLabels.join(" → ")}
</div>
)}
{item.examples && item.examples.length > 0 && (
<div className="mt-1 space-y-1">
{item.examples.slice(0, 2).map((example) => (
<ProjectionPanelItemRow
key={example.caseId ?? example.title}
item={example}
t={t}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}

function buildItemMeta(item: ProjectionPanelItem, t: (key: string, opts?: { count?: number }) => string): string[] {
const lines: string[] = [];
if (item.caseId) lines.push(`${t("projectionBootstrap.meta.caseId")}: ${item.caseId}`);
if (item.sourceSession) lines.push(`${t("projectionBootstrap.meta.sourceSession")}: ${item.sourceSession}`);
if (item.taskId) lines.push(`${t("projectionBootstrap.meta.taskId")}: ${item.taskId}`);
if (item.owner) lines.push(`${t("projectionBootstrap.meta.owner")}: ${item.owner}`);
if (item.state) lines.push(`${t("projectionBootstrap.meta.state")}: ${item.state}`);
if (item.reason) lines.push(`${t("projectionBootstrap.meta.reason")}: ${item.reason}`);
if (item.path) lines.push(`${t("projectionBootstrap.meta.path")}: ${item.path}`);
return lines;
}

function normalizeTags(tags?: string[]): string[] {
return tags?.filter(Boolean) ?? [];
}

const severityClasses: Record<ProjectionPanel["severity"], string> = {
info: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
warn: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300",
error: "bg-red-100 text-red-700 dark:bg-red-950/60 dark:text-red-300",
success: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300",
};

Loading