feat(FN-4702): complete Step 4 — add settings install affordance
Fusion-Task-Id: FN-4702 Fusion-Task-Lineage: 378b46bc-2e71-43bf-9ff7-85e7aed8bf85
This commit is contained in:
committed by
gsxdsm
parent
1eef42a912
commit
c345576340
@@ -1899,6 +1899,7 @@ function AppInner() {
|
||||
settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }}
|
||||
onSettingsClose={handleSettingsClose}
|
||||
onReopenOnboarding={reopenOnboardingWithNav}
|
||||
onOpenApprovals={() => handleTaskViewChange("mailbox")}
|
||||
/>
|
||||
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
|
||||
{shellApi && (
|
||||
|
||||
@@ -76,6 +76,8 @@ interface AppModalsProps {
|
||||
onSettingsClose?: () => void;
|
||||
/** Optional callback to reopen the onboarding guide from Settings. Closes Settings and opens ModelOnboardingModal. */
|
||||
onReopenOnboarding?: () => void;
|
||||
/** Optional callback to open mailbox approvals from Settings. */
|
||||
onOpenApprovals?: (approvalId?: string) => void;
|
||||
}
|
||||
|
||||
export function AppModals({
|
||||
@@ -94,6 +96,7 @@ export function AppModals({
|
||||
settings,
|
||||
onSettingsClose,
|
||||
onReopenOnboarding,
|
||||
onOpenApprovals,
|
||||
}: AppModalsProps) {
|
||||
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
|
||||
const detailTask = modalManager.detailTask
|
||||
@@ -194,6 +197,7 @@ export function AppModals({
|
||||
dashboardFontScalePct={settings.dashboardFontScalePct}
|
||||
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
|
||||
onReopenOnboarding={onReopenOnboarding}
|
||||
onOpenApprovals={onOpenApprovals}
|
||||
/>
|
||||
</Suspense>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
@@ -49,6 +49,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
|
||||
@@ -374,6 +375,8 @@ interface SettingsModalProps {
|
||||
onDashboardFontScaleChange?: (scalePct: number) => void;
|
||||
/** Optional callback when user wants to reopen the onboarding guide */
|
||||
onReopenOnboarding?: () => void;
|
||||
/** Optional callback to open approvals/mailbox view. */
|
||||
onOpenApprovals?: (approvalId?: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
@@ -388,8 +391,10 @@ export function SettingsModal({
|
||||
dashboardFontScalePct = 100,
|
||||
onDashboardFontScaleChange,
|
||||
onReopenOnboarding,
|
||||
onOpenApprovals,
|
||||
}: SettingsModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const worktrunkInstall = useWorktrunkInstallStatus(projectId);
|
||||
const viewportMode = useViewportMode();
|
||||
useMobileScrollLock(true);
|
||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({
|
||||
@@ -4166,6 +4171,46 @@ export function SettingsModal({
|
||||
<code>fail</code> stops on worktrunk errors for explicit operator recovery; <code>fallback-native</code> keeps progress moving by switching to Fusion's built-in worktree backend.
|
||||
</small>
|
||||
</div>
|
||||
<div className="form-group" data-testid="worktrunk-install-affordance">
|
||||
{worktrunkInstall.status === "installed" && (
|
||||
<small className="settings-muted">
|
||||
worktrunk {worktrunkInstall.version ?? ""} installed at {worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
|
||||
</small>
|
||||
)}
|
||||
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void worktrunkInstall.requestInstall()}
|
||||
disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}
|
||||
>
|
||||
Install worktrunk binary
|
||||
</button>
|
||||
<small className="settings-muted">Enable worktrunk and request approval to install the pinned release.</small>
|
||||
</>
|
||||
)}
|
||||
{worktrunkInstall.status === "pending-approval" && (
|
||||
<>
|
||||
<small className="settings-muted">Awaiting approval — open Approvals to continue.</small>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}
|
||||
>
|
||||
Open Approvals
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (
|
||||
<>
|
||||
<small style={{ color: "var(--color-error)" }}>{worktrunkInstall.error ?? "Worktrunk install failed."}</small>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void worktrunkInstall.requestInstall()}>
|
||||
Try again
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case "commands":
|
||||
|
||||
@@ -60,6 +60,7 @@ const mockFetchCursorCliStatus = vi.fn();
|
||||
const mockSetCursorCliEnabled = vi.fn();
|
||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||
const mockConfirm = vi.fn();
|
||||
const mockUseWorktrunkInstallStatus = vi.fn();
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||
@@ -161,6 +162,10 @@ vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
|
||||
useWorkspaceFileBrowser: (...args: unknown[]) => mockUseWorkspaceFileBrowser(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
|
||||
useWorktrunkInstallStatus: (...args: unknown[]) => mockUseWorktrunkInstallStatus(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../FileBrowser", () => ({
|
||||
FileBrowser: ({ onSelectFile }: { onSelectFile: (path: string) => void }) => (
|
||||
<div data-testid="mock-overlap-file-browser">
|
||||
@@ -405,6 +410,15 @@ describe("SettingsModal", () => {
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
});
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockUseWorktrunkInstallStatus.mockReturnValue({
|
||||
status: "missing",
|
||||
requestInstall: vi.fn(),
|
||||
requesting: false,
|
||||
version: undefined,
|
||||
installPath: undefined,
|
||||
pendingApprovalId: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||
mockCheckForUpdates.mockResolvedValue(undefined);
|
||||
mockFetchRemoteSettings.mockResolvedValue({
|
||||
@@ -2139,6 +2153,42 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByText(/Disabled because Worktrunk integration is enabled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ status: "missing", button: "Install worktrunk binary", action: "request" },
|
||||
{ status: "pending-approval", button: "Open Approvals", action: "open" },
|
||||
{ status: "denied", button: "Try again", action: "request" },
|
||||
{ status: "installed", text: "installed at" },
|
||||
])("renders install affordance state %#", async (scenario) => {
|
||||
const requestInstall = vi.fn();
|
||||
const onOpenApprovals = vi.fn();
|
||||
mockUseWorktrunkInstallStatus.mockReturnValue({
|
||||
status: scenario.status,
|
||||
requestInstall,
|
||||
requesting: false,
|
||||
version: "v1.2.3",
|
||||
installPath: "~/.fusion/bin/worktrunk",
|
||||
pendingApprovalId: "apr-1",
|
||||
error: "Denied",
|
||||
});
|
||||
|
||||
renderModal({ initialSection: "worktrees", onOpenApprovals });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
if (scenario.text) {
|
||||
expect(screen.getByText(/installed at/i)).toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = screen.getByRole("button", { name: scenario.button });
|
||||
await userEvent.click(button);
|
||||
|
||||
if (scenario.action === "request") {
|
||||
expect(requestInstall).toHaveBeenCalledTimes(1);
|
||||
} else {
|
||||
expect(onOpenApprovals).toHaveBeenCalledWith("apr-1");
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["fail", "fallback-native"])("saves worktrunk payload and defaults onFailure on first enable (%s)", async (onFailure) => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
94
packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts
Normal file
94
packages/dashboard/app/hooks/useWorktrunkInstallStatus.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
export type WorktrunkInstallUiStatus = "installed" | "missing" | "pending-approval" | "installing" | "denied" | "failed";
|
||||
|
||||
interface WorktrunkInstallStatusResponse {
|
||||
status: WorktrunkInstallUiStatus;
|
||||
version?: string;
|
||||
installPath?: string;
|
||||
pendingApprovalId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function withProjectId(path: string, projectId?: string): string {
|
||||
if (!projectId) return path;
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
return `${path}${separator}projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as T;
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string }).error ?? `Request failed (${response.status})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function useWorktrunkInstallStatus(projectId?: string) {
|
||||
const [status, setStatus] = useState<WorktrunkInstallStatusResponse>({ status: "missing" });
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await requestJson<WorktrunkInstallStatusResponse>(withProjectId("/api/worktrunk/status", projectId));
|
||||
setStatus(next);
|
||||
} catch (err) {
|
||||
setStatus({ status: "failed", error: err instanceof Error ? err.message : "Failed to load worktrunk status" });
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeSse(withProjectId("/api/events", projectId), {
|
||||
events: {
|
||||
"approval:updated": () => {
|
||||
if (status.pendingApprovalId) void refresh();
|
||||
},
|
||||
"approval:decided": () => {
|
||||
if (status.pendingApprovalId) void refresh();
|
||||
},
|
||||
},
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [projectId, refresh, status.pendingApprovalId]);
|
||||
|
||||
const requestInstall = useCallback(async () => {
|
||||
setRequesting(true);
|
||||
setStatus((current) => ({ ...current, status: "installing", error: undefined }));
|
||||
try {
|
||||
const result = await requestJson<WorktrunkInstallStatusResponse & { approvalRequestId?: string }>(
|
||||
withProjectId("/api/worktrunk/install-request", projectId),
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
setStatus({
|
||||
status: result.status,
|
||||
version: result.version,
|
||||
installPath: result.installPath,
|
||||
pendingApprovalId: result.pendingApprovalId ?? result.approvalRequestId,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({ status: "failed", error: err instanceof Error ? err.message : "Failed to request install" });
|
||||
} finally {
|
||||
setRequesting(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
return {
|
||||
...status,
|
||||
requestInstall,
|
||||
requesting,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user