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:
9
.changeset/usage-indicator-resizable.md
Normal file
9
.changeset/usage-indicator-resizable.md
Normal 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.
|
||||
@@ -8,6 +8,8 @@ const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
|
||||
const dashboardClientDest = join(__dirname, "dist", "client");
|
||||
const piClaudeCliSrc = join(__dirname, "..", "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>
|
||||
<html lang="en">
|
||||
<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)) {
|
||||
rmSync(dashboardClientDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
.usage-modal {
|
||||
width: 520px;
|
||||
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 {
|
||||
@@ -71,7 +80,8 @@
|
||||
|
||||
/* Content area */
|
||||
.usage-content {
|
||||
max-height: 60vh;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -466,6 +476,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-actions-left {
|
||||
@@ -489,11 +500,10 @@
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.usage-content {
|
||||
max-height: calc(100vh - 120px);
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.usage-actions {
|
||||
@@ -526,12 +536,29 @@
|
||||
|
||||
.usage-modal--popover {
|
||||
width: 420px;
|
||||
max-width: min(420px, calc(100vw - var(--space-md) * 2));
|
||||
max-height: 70vh;
|
||||
max-width: calc(100vw - var(--space-md) * 2);
|
||||
max-height: 80vh;
|
||||
min-width: 320px;
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
background: var(--surface);
|
||||
z-index: 101;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,32 @@ function getUsageColorClass(percentUsed: number): string {
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const stored = getScopedItem(HIDDEN_WINDOWS_STORAGE_KEY, projectId);
|
||||
@@ -447,8 +473,42 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
getHiddenWindows(projectId)
|
||||
);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const wasOpenRef = useRef(isOpen);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -584,15 +644,24 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
const showDesktopPopover = Boolean(anchorRect && isDesktopViewport);
|
||||
const desktopGap = 8;
|
||||
const maxTopPadding = 12;
|
||||
const defaultPopoverWidth = 420;
|
||||
const popoverWidth = savedSize?.width ?? defaultPopoverWidth;
|
||||
const desktopTop = showDesktopPopover
|
||||
? Math.min((anchorRect?.bottom ?? 0) + desktopGap, window.innerHeight - maxTopPadding)
|
||||
: undefined;
|
||||
const desktopRight = showDesktopPopover
|
||||
? Math.max(window.innerWidth - (anchorRect?.right ?? 0), 0)
|
||||
// Anchor popover so its right edge aligns with the anchor button's right edge,
|
||||
// but use `left` positioning so native resize (bottom-right handle) feels natural.
|
||||
const desktopLeft = showDesktopPopover
|
||||
? Math.max(8, (anchorRect?.right ?? 0) - popoverWidth)
|
||||
: undefined;
|
||||
|
||||
const sizeStyle: CSSProperties = isDesktopViewport && savedSize
|
||||
? { width: savedSize.width, height: savedSize.height }
|
||||
: {};
|
||||
|
||||
const usageContent = (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`usage-modal${showDesktopPopover ? " usage-modal--popover" : " modal"}`}
|
||||
data-testid="usage-modal"
|
||||
style={
|
||||
@@ -600,9 +669,10 @@ export function UsageIndicator({ isOpen, onClose, projectId, anchorRect }: Usage
|
||||
? ({
|
||||
position: "fixed",
|
||||
top: desktopTop,
|
||||
right: desktopRight,
|
||||
left: desktopLeft,
|
||||
...sizeStyle,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
: sizeStyle
|
||||
}
|
||||
>
|
||||
<div className="modal-header">
|
||||
|
||||
@@ -252,7 +252,7 @@ describe("UsageIndicator", () => {
|
||||
const modal = screen.getByTestId("usage-modal") as HTMLElement;
|
||||
expect(modal).toHaveClass("usage-modal--popover");
|
||||
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", () => {
|
||||
|
||||
@@ -89,10 +89,11 @@ describe("projectStorage", () => {
|
||||
"kb-mission-last-goal",
|
||||
"kb-usage-view-mode",
|
||||
"kb-usage-hidden-windows",
|
||||
"kb-usage-modal-size",
|
||||
"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", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-mission-last-goal",
|
||||
"kb-usage-view-mode",
|
||||
"kb-usage-hidden-windows",
|
||||
"kb-usage-modal-size",
|
||||
"kb-chat-active-session",
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user