FN-6119: open CE docs in built-in file viewer

Expose the dashboard file viewer to plugin views and wire Compound Engineering artifacts to it.

- add an openFile callback to the dashboard plugin view context and pass through the app host implementation
- switch Compound Engineering artifact Open actions to the built-in file viewer with matching styling and coverage
- document the new plugin context capability and add a published changeset for the CLI package

Files changed:
 .changeset/ce-docs-built-in-viewer.md              |  5 +++
 docs/PLUGIN_AUTHORING.md                           |  2 +-
 packages/dashboard/app/App.tsx                     |  1 +
 packages/dashboard/app/plugins/types.ts            |  2 ++
 .../src/dashboard-interop.d.ts                     |  1 +
 .../src/dashboard/CompoundEngineeringView.css      | 16 +++++++++
 .../src/dashboard/CompoundEngineeringView.tsx      | 24 +++++++-------
 .../__tests__/CompoundEngineeringView.test.tsx     | 38 ++++++++++++++++++++++
 8 files changed, 76 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6119

Fusion-Task-Lineage: 8feb461c-b1d2-4059-9aa1-ffc756d15196
This commit is contained in:
gsxdsm
2026-06-09 13:54:59 -07:00
parent 9819000526
commit 30ba1f005a
8 changed files with 76 additions and 13 deletions

View File

@@ -24,6 +24,7 @@ declare module "@fusion/dashboard/app/plugins/types" {
tasks: Task[];
workflowSteps: WorkflowStep[];
openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
openFile: (path: string, options?: { workspace?: string; line?: number; col?: number }) => void;
renderTaskCard?: (task: Task | TaskDetail) => ReactNode;
addToast?: (message: string, type?: PluginToastType) => void;
subscribePluginEvents?: (

View File

@@ -135,6 +135,22 @@
opacity: 0.55;
}
.ce-artifact-open {
appearance: none;
background: none;
border: none;
color: var(--color-primary, #2563eb);
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
.ce-artifact-open:hover,
.ce-artifact-open:focus-visible {
color: var(--color-primary-hover, #1d4ed8);
}
.ce-artifact-error .ce-artifact-error-msg {
color: var(--color-danger, #d23);
font-size: 0.78rem;

View File

@@ -7,7 +7,6 @@ import { useArtifacts } from "./hooks/useArtifacts.js";
import { useViewportMode } from "./hooks/useViewportMode.js";
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
import { getArtifactPreviewUrl } from "./hooks/api.js";
import { CeFlow } from "./CeFlow.js";
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js";
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
@@ -170,14 +169,14 @@ function EmptyState({ onStart }: { onStart: () => void }) {
function ArtifactRow({
entry,
projectId,
onSelect,
selected,
openFile,
}: {
entry: CeArtifactEntry;
projectId?: string;
onSelect: (id: string) => void;
selected: boolean;
openFile?: PluginDashboardViewContext["openFile"];
}) {
if (entry.kind === "error") {
return (
@@ -196,28 +195,28 @@ function ArtifactRow({
<span className="ce-artifact-name">{entry.name}</span>
<span className="ce-artifact-path">{entry.path}</span>
</button>
<a
<button
type="button"
className="ce-artifact-open"
href={getArtifactPreviewUrl(entry.id, projectId)}
target="_blank"
rel="noreferrer"
data-testid="ce-artifact-open"
onClick={() => openFile?.(entry.path)}
>
Open
</a>
</button>
</li>
);
}
function StageGroup({
group,
projectId,
onSelect,
selectedId,
openFile,
}: {
group: CeArtifactGroup;
projectId?: string;
onSelect: (id: string) => void;
selectedId?: string;
openFile?: PluginDashboardViewContext["openFile"];
}) {
const empty = group.entries.length === 0;
return (
@@ -236,9 +235,9 @@ function StageGroup({
<ArtifactRow
key={entry.id}
entry={entry}
projectId={projectId}
onSelect={onSelect}
selected={selectedId === entry.id}
openFile={openFile}
/>
))}
</ul>
@@ -261,6 +260,7 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
// host doesn't supply it, the hook falls back to polling.
const subscribePluginEvents = (props.context as PluginDashboardViewContext | undefined)
?.subscribePluginEvents;
const openFile = props.context?.openFile;
const subscribe = useMemo<CeSessionSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined;
return (sessionId, _projectId, onSessionEvent) =>
@@ -415,9 +415,9 @@ export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
<StageGroup
key={group.stage}
group={group}
projectId={projectId}
onSelect={setSelectedId}
selectedId={selectedId}
openFile={openFile}
/>
))}
</div>

View File

@@ -119,6 +119,44 @@ describe("CompoundEngineeringView", () => {
expect(screen.getAllByTestId("ce-group-empty").length).toBeGreaterThan(0);
});
it("opens an artifact in the built-in file viewer via context.openFile", async () => {
const openFile = vi.fn();
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
}),
);
render(
<CompoundEngineeringView
projectId="p1"
enabledOverride
context={{ openFile, tasks: [], workflowSteps: [], openTaskDetail: vi.fn() }}
/>,
);
await screen.findByTestId("ce-artifact");
fireEvent.click(screen.getByTestId("ce-artifact-open"));
expect(openFile).toHaveBeenCalledWith("STRATEGY.md");
});
it("renders artifact open button without crashing when openFile is not in context", async () => {
listArtifacts.mockResolvedValue(
makeResult({
strategy: [
{ kind: "artifact", id: "strategy:STRATEGY.md", stage: "strategy", path: "STRATEGY.md", name: "STRATEGY.md", size: 10, updatedAt: 1 },
],
}),
);
render(<CompoundEngineeringView projectId="p1" enabledOverride />);
await screen.findByTestId("ce-artifact");
const open = screen.getByTestId("ce-artifact-open");
expect(open).toBeInTheDocument();
fireEvent.click(open);
});
it("renders an error entry for an unreadable artifact (not a crash or silent drop)", async () => {
listArtifacts.mockResolvedValue(
makeResult({