feat(FN-2985): add droid CLI path reconciliation extension

Adds a new pi extension that reconciles droid CLI paths, with corresponding staging support in the tsup build config for bundling the droid-cli. The extension is wired into the engine and exported from core, with tests covering the path reconciliation logic.

Fusion-Task-Id: FN-2985
This commit is contained in:
Fusion
2026-05-01 12:30:20 -07:00
committed by gsxdsm
parent 337f84aa5f
commit 72ed14326b
7 changed files with 144 additions and 13 deletions

View File

@@ -0,0 +1,9 @@
---
"@runfusion/fusion": patch
---
Fix the dashboard usage indicator popup so the footer (Last updated timestamp, Refresh, and Close buttons) is always visible, and make the popup resizable with the size persisted across sessions.
- Changed the modal/popover to a flex column so the scrollable provider list can shrink while the header and action footer stay pinned. Previously the inner content used `max-height: 60vh` while the popover wrapper capped at 70vh with `overflow: hidden`, which pushed the footer below the visible area on shorter viewports or when many providers were configured.
- Added native `resize: both` to the desktop popover and modal variants, with sensible min sizes. The popover now anchors via `left` (computed from the trigger button's right edge) instead of `right`, so dragging the bottom-right resize handle behaves as expected.
- Persist the user's chosen width/height per project in `localStorage` under a new `kb-usage-modal-size` scoped key (debounced via `ResizeObserver`). The saved size is reapplied on next open.

View File

@@ -8,6 +8,8 @@ const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
const dashboardClientDest = join(__dirname, "dist", "client"); const dashboardClientDest = join(__dirname, "dist", "client");
const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli"); const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli");
const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli"); const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli");
const droidCliSrc = join(__dirname, "..", "droid-cli");
const droidCliDest = join(__dirname, "dist", "droid-cli");
const dashboardClientStub = `<!doctype html> const dashboardClientStub = `<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -65,6 +67,27 @@ export default defineConfig({
); );
} }
// Stage the vendored @fusion/droid-cli pi extension into dist/, following
// the same pattern as pi-claude-cli above. The extension ships raw .ts
// source that pi loads via jiti at runtime, so it cannot be bundled by
// esbuild. This lets us drop @fusion/droid-cli from the published
// package's dependencies — the workspace package is private and would 404
// on `pnpm install` of @runfusion/fusion otherwise.
if (existsSync(droidCliDest)) {
rmSync(droidCliDest, { recursive: true, force: true });
}
if (existsSync(droidCliSrc)) {
mkdirSync(droidCliDest, { recursive: true });
cpSync(join(droidCliSrc, "index.ts"), join(droidCliDest, "index.ts"));
cpSync(join(droidCliSrc, "src"), join(droidCliDest, "src"), { recursive: true });
cpSync(join(droidCliSrc, "package.json"), join(droidCliDest, "package.json"));
console.log("Copied droid-cli extension to dist/droid-cli/");
} else {
console.warn(
`WARNING: droid-cli source not found at ${droidCliSrc}; useDroidCli will not work in the published package.`,
);
}
if (existsSync(dashboardClientDest)) { if (existsSync(dashboardClientDest)) {
rmSync(dashboardClientDest, { recursive: true, force: true }); rmSync(dashboardClientDest, { recursive: true, force: true });
} }

View File

@@ -12,6 +12,15 @@
.usage-modal { .usage-modal {
width: 520px; width: 520px;
max-width: 90vw; max-width: 90vw;
max-height: 85vh;
display: flex;
flex-direction: column;
min-width: 320px;
min-height: 280px;
}
.usage-modal > .modal-header {
flex-shrink: 0;
} }
.usage-header { .usage-header {
@@ -71,7 +80,8 @@
/* Content area */ /* Content area */
.usage-content { .usage-content {
max-height: 60vh; flex: 1 1 auto;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 0; padding: 0;
} }
@@ -466,6 +476,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-md); gap: var(--space-md);
flex-shrink: 0;
} }
.usage-actions-left { .usage-actions-left {
@@ -489,11 +500,10 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: 100vh; max-height: 100vh;
min-width: 0;
min-height: 0;
border-radius: 0; border-radius: 0;
} resize: none;
.usage-content {
max-height: calc(100vh - 120px);
} }
.usage-actions { .usage-actions {
@@ -526,12 +536,29 @@
.usage-modal--popover { .usage-modal--popover {
width: 420px; width: 420px;
max-width: min(420px, calc(100vw - var(--space-md) * 2)); max-width: calc(100vw - var(--space-md) * 2);
max-height: 70vh; max-height: 80vh;
min-width: 320px;
min-height: 280px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
background: var(--surface); background: var(--surface);
z-index: 101; z-index: 101;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
resize: both;
}
.usage-modal.modal {
resize: both;
overflow: hidden;
}
@media (max-width: 768px) {
.usage-modal--popover,
.usage-modal.modal {
resize: none;
}
} }

View File

@@ -82,6 +82,32 @@ function getUsageColorClass(percentUsed: number): string {
} }
const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows"; const HIDDEN_WINDOWS_STORAGE_KEY = "kb-usage-hidden-windows";
const MODAL_SIZE_STORAGE_KEY = "kb-usage-modal-size";
interface ModalSize {
width: number;
height: number;
}
function getSavedModalSize(projectId: string | undefined): ModalSize | null {
const stored = getScopedItem(MODAL_SIZE_STORAGE_KEY, projectId);
if (!stored) return null;
try {
const parsed = JSON.parse(stored);
if (
parsed &&
typeof parsed.width === "number" &&
typeof parsed.height === "number" &&
parsed.width > 0 &&
parsed.height > 0
) {
return { width: parsed.width, height: parsed.height };
}
} catch {
// ignore
}
return null;
}
function getHiddenWindows(projectId: string | undefined): Record<string, string[]> { function getHiddenWindows(projectId: string | undefined): Record<string, string[]> {
const stored = getScopedItem(HIDDEN_WINDOWS_STORAGE_KEY, projectId); const stored = getScopedItem(HIDDEN_WINDOWS_STORAGE_KEY, projectId);
@@ -447,8 +473,42 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
getHiddenWindows(projectId) getHiddenWindows(projectId)
); );
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(isOpen); const wasOpenRef = useRef(isOpen);
const hasCompletedInitialFetchRef = useRef(false); const hasCompletedInitialFetchRef = useRef(false);
const [savedSize, setSavedSize] = useState<ModalSize | null>(() => getSavedModalSize(projectId));
useEffect(() => {
setSavedSize(getSavedModalSize(projectId));
}, [projectId]);
// Persist user resizes via ResizeObserver (debounced).
useEffect(() => {
if (!isOpen || !isDesktopViewport) return;
const el = modalRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
let timer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
if (width <= 0 || height <= 0) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
setScopedItem(
MODAL_SIZE_STORAGE_KEY,
JSON.stringify({ width: Math.round(width), height: Math.round(height) }),
projectId
);
}, 250);
});
observer.observe(el);
return () => {
if (timer) clearTimeout(timer);
observer.disconnect();
};
}, [isOpen, isDesktopViewport, projectId]);
// Reset initial fetch flag when modal closes to show skeleton on next open // Reset initial fetch flag when modal closes to show skeleton on next open
useEffect(() => { useEffect(() => {
@@ -584,15 +644,24 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport); const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
const desktopGap = 8; const desktopGap = 8;
const maxTopPadding = 12; const maxTopPadding = 12;
const defaultPopoverWidth = 420;
const popoverWidth = savedSize?.width ?? defaultPopoverWidth;
const desktopTop = showDesktopPopover const desktopTop = showDesktopPopover
? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding) ? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding)
: undefined; : undefined;
const desktopRight = showDesktopPopover // Anchor popover so its right edge aligns with the anchor button's right edge,
? Math.max(window.innerWidth - (anchorRect?.right ?? 0), 0) // but use `left` positioning so native resize (bottom-right handle) feels natural.
const desktopLeft = showDesktopPopover
? Math.max(8, (anchorRect?.right ?? 0) - popoverWidth)
: undefined; : undefined;
const sizeStyle: CSSProperties = isDesktopViewport && savedSize
? { width: savedSize.width, height: savedSize.height }
: {};
const usageContent = ( const usageContent = (
<div <div
ref={modalRef}
className={`usage-modal${showDesktopPopover ? " usage-modal--popover" : " modal"}`} className={`usage-modal${showDesktopPopover ? " usage-modal--popover" : " modal"}`}
data-testid="usage-modal" data-testid="usage-modal"
style={ style={
@@ -600,9 +669,10 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
? ({ ? ({
position: "fixed", position: "fixed",
top: desktopTop, top: desktopTop,
right: desktopRight, left: desktopLeft,
...sizeStyle,
} as CSSProperties) } as CSSProperties)
: undefined : sizeStyle
} }
> >
<div className="modal-header"> <div className="modal-header">

View File

@@ -252,7 +252,7 @@ describe("UsageIndicator", () => {
const modal = screen.getByTestId("usage-modal") as HTMLElement; const modal = screen.getByTestId("usage-modal") as HTMLElement;
expect(modal).toHaveClass("usage-modal--popover"); expect(modal).toHaveClass("usage-modal--popover");
expect(modal.style.top).toBe("88px"); expect(modal.style.top).toBe("88px");
expect(modal.style.right).toBe("84px"); expect(modal.style.left).toBe("520px");
}); });
it("renders as full-screen modal when anchorRect is null", () => { it("renders as full-screen modal when anchorRect is null", () => {

View File

@@ -89,10 +89,11 @@ describe("projectStorage", () => {
"kb-mission-last-goal", "kb-mission-last-goal",
"kb-usage-view-mode", "kb-usage-view-mode",
"kb-usage-hidden-windows", "kb-usage-hidden-windows",
"kb-usage-modal-size",
"kb-chat-active-session", "kb-chat-active-session",
]), ]),
); );
expect(PROJECT_STORAGE_KEYS).toHaveLength(16); expect(PROJECT_STORAGE_KEYS).toHaveLength(17);
}); });
it("has no overlap between global and project-scoped keys", () => { it("has no overlap between global and project-scoped keys", () => {

View File

@@ -22,6 +22,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-mission-last-goal", "kb-mission-last-goal",
"kb-usage-view-mode", "kb-usage-view-mode",
"kb-usage-hidden-windows", "kb-usage-hidden-windows",
"kb-usage-modal-size",
"kb-chat-active-session", "kb-chat-active-session",
]; ];