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 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user