-
-
-
-
-
- {selectedColumn
- ? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
- : t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
-
-
- {onNewTask ? (
-
- ) : null}
-
+ {renderPrimaryActionCluster()}
{selectedColumn ? (
diff --git a/packages/dashboard/app/components/NewAgentDialog.css b/packages/dashboard/app/components/NewAgentDialog.css
index 7be7e1217c..c404c6fc99 100644
--- a/packages/dashboard/app/components/NewAgentDialog.css
+++ b/packages/dashboard/app/components/NewAgentDialog.css
@@ -262,8 +262,15 @@
}
.agent-role-option-icon {
- font-size: calc(var(--space-lg) + var(--space-xs));
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
line-height: 1;
+ color: currentColor;
+}
+
+.agent-role-option-icon svg {
+ flex-shrink: 0;
}
.agent-role-option-label {
diff --git a/packages/dashboard/app/components/NewAgentDialog.tsx b/packages/dashboard/app/components/NewAgentDialog.tsx
index 6f29d6ac0a..b98ddb6366 100644
--- a/packages/dashboard/app/components/NewAgentDialog.tsx
+++ b/packages/dashboard/app/components/NewAgentDialog.tsx
@@ -33,14 +33,14 @@ export interface NewAgentDialogProps {
onPrefillDraft?: (draft: AgentOnboardingSummary | null) => void;
}
-const AGENT_ROLES: { value: AgentCapability; icon: string }[] = [
- { value: "triage", icon: "⊕" },
- { value: "executor", icon: "▶" },
- { value: "reviewer", icon: "⊙" },
- { value: "merger", icon: "⊞" },
- { value: "scheduler", icon: "◷" },
- { value: "engineer", icon: "⎔" },
- { value: "custom", icon: "✦" },
+const AGENT_ROLES: { value: AgentCapability }[] = [
+ { value: "triage" },
+ { value: "executor" },
+ { value: "reviewer" },
+ { value: "merger" },
+ { value: "scheduler" },
+ { value: "engineer" },
+ { value: "custom" },
];
interface RuntimeConfig {
@@ -170,6 +170,11 @@ export function NewAgentDialog({
const selectedModel = runtimeConfig.model.includes("/")
? runtimeConfig.model
: "";
+ /*
+ * FNXC:AgentRoles 2026-06-23-00:19:
+ * Role selection should feel professional and model-aware, not cartoony. Use the selected model provider mark on each role card and a neutral default mark before selection; role identity stays in text labels.
+ */
+ const selectedModelProvider = selectedModel ? selectedModel.split("/")[0] : "default";
const handleGenerated = useCallback((spec: AgentGenerationSpec) => {
// Map generated role to AgentCapability, default to "custom" if unrecognized
@@ -556,7 +561,9 @@ export function NewAgentDialog({
className={`agent-role-option${role === r.value ? " selected" : ""}`}
onClick={() => setRole(r.value)}
>
-
{r.icon}
+
+
+
{getRoleLabel(r.value)}
))}
@@ -740,7 +747,7 @@ export function NewAgentDialog({
diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css
index 887cae1453..b0d16768d1 100644
--- a/packages/dashboard/app/components/NewTaskModal.css
+++ b/packages/dashboard/app/components/NewTaskModal.css
@@ -29,6 +29,7 @@ FNXC:NewTask 2026-06-22-20:30:
Floating panel positioned by state-driven inline left/top/width/height. min/max keep content usable and the panel on-screen; `resize: none` because the corner/edge handles own resizing (the native grip conflicts with the pointer handlers). `pointer-events: auto` re-enables interaction on the panel only. Desktop only — mobile keeps the full-screen keyboard-aware sheet.
*/
.new-task-modal--floating {
+ --floating-window-shadow: var(--shadow-lg);
position: fixed;
display: flex;
flex-direction: column;
@@ -38,7 +39,11 @@ Floating panel positioned by state-driven inline left/top/width/height. min/max
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
- box-shadow: var(--shadow-xl);
+ /*
+ FNXC:FloatingWindow 2026-06-23-23:32:
+ Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across New Task, Terminal, Right Dock, and shared FloatingWindow panels.
+ */
+ box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
.new-task-modal--floating .modal-body {
@@ -53,6 +58,7 @@ Header is the drag handle. `touch-action: none` (matching the resize handles) ha
cursor: grab;
user-select: none;
touch-action: none;
+ min-height: 48px;
}
.new-task-modal__header--draggable:active {
diff --git a/packages/dashboard/app/components/RightDock.css b/packages/dashboard/app/components/RightDock.css
index 6abf93c221..28259914c7 100644
--- a/packages/dashboard/app/components/RightDock.css
+++ b/packages/dashboard/app/components/RightDock.css
@@ -196,6 +196,7 @@ FNXC:RightDock 2026-06-22-17:40:
Floating panel positioned by state-driven inline `left/top/width/height`. min/max keep content usable and the panel on-screen. `resize: none` because resizing is handled by the corner/edge handles (the native grip conflicts with the drag/resize pointer handlers). `pointer-events: auto` re-enables interaction on the panel only.
*/
.right-dock-expand-modal--floating {
+ --floating-window-shadow: var(--shadow-lg);
position: fixed;
min-width: calc(var(--space-2xl) * 7.5);
min-height: calc(var(--space-2xl) * 5.83);
@@ -203,7 +204,11 @@ Floating panel positioned by state-driven inline `left/top/width/height`. min/ma
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
- box-shadow: var(--shadow-xl);
+ /*
+ FNXC:FloatingWindow 2026-06-23-23:32:
+ Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Right Dock, New Task, Terminal, and shared FloatingWindow panels.
+ */
+ box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css
index 777fd69232..abb8380888 100644
--- a/packages/dashboard/app/components/ScriptsModal.css
+++ b/packages/dashboard/app/components/ScriptsModal.css
@@ -1962,6 +1962,16 @@ queries already apply (the container-query rule above also fires and produces th
══════════════════════════════════════════════════════════════════ */
/* Modal size override - flex layout for sidebar + content */
+/*
+FNXC:GitManager 2026-06-23-23:52:
+Sidebar-launched floating modals should not dim, blur, or block the app behind them. Match the Files/RightDock floating-window model: the overlay is transparent and click-through, while the Git Manager panel remains interactive. Dismissal stays on Escape/close button instead of backdrop click.
+*/
+.modal-overlay.git-manager-modal-overlay {
+ background: transparent;
+ backdrop-filter: none;
+ pointer-events: none;
+}
+
.modal.gm-modal {
width: min(95vw, 1400px);
max-width: 95vw;
@@ -1973,6 +1983,7 @@ queries already apply (the container-query rule above also fires and produces th
flex-direction: column;
overflow: hidden;
resize: both;
+ pointer-events: auto;
}
/*
diff --git a/packages/dashboard/app/components/TerminalModal.css b/packages/dashboard/app/components/TerminalModal.css
index 8f1242f8a6..72219fbfd2 100644
--- a/packages/dashboard/app/components/TerminalModal.css
+++ b/packages/dashboard/app/components/TerminalModal.css
@@ -83,6 +83,7 @@ Only the FLOATING terminal joins the shared cross-type floating stack. Reset the
}
.modal.terminal-modal.terminal-modal--docked {
+ --floating-window-shadow: var(--shadow-lg);
position: fixed;
left: 0;
right: 0;
@@ -96,7 +97,7 @@ Only the FLOATING terminal joins the shared cross-type floating stack. Reset the
resize: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
pointer-events: auto;
- box-shadow: var(--shadow-xl);
+ box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
@@ -127,6 +128,7 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
}
.modal.terminal-modal.terminal-modal--floating {
+ --floating-window-shadow: var(--shadow-lg);
position: fixed;
left: var(--terminal-float-x);
top: var(--terminal-float-y);
@@ -138,7 +140,11 @@ Larger grab target for the docked terminal top resize handle: it straddles the p
max-height: calc(100dvh - (var(--space-lg) * 2));
resize: none;
pointer-events: auto;
- box-shadow: var(--shadow-xl);
+ /*
+ FNXC:FloatingWindow 2026-06-23-23:32:
+ Floating modals share a gentle theme-controlled elevation token. Use the app shadow fallback instead of undefined --shadow-xl so themes can opt out or tune shadow strength consistently across Terminal, New Task, Right Dock, and shared FloatingWindow panels.
+ */
+ box-shadow: var(--floating-window-shadow, var(--shadow-lg));
}
/*
@@ -149,6 +155,7 @@ The floating-mode header is the move grip. `touch-action: none` is required so a
cursor: grab;
user-select: none;
touch-action: none;
+ min-height: 48px;
}
.terminal-header--draggable:active {
diff --git a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx
index 2e4b7ec360..e23d491228 100644
--- a/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentLogViewer.test.tsx
@@ -795,6 +795,20 @@ describe("AgentLogViewer", () => {
expect(timestamp.style.opacity).toBe("");
});
+ it("renders the agent badge as a sticky overlay on a full-width text block", () => {
+ const entries = [makeEntry({ text: "long executor output", type: "text", agent: "executor" })];
+ const { container } = render(
);
+ const block = container.querySelector(".agent-log-text") as HTMLElement;
+ const badgeRow = container.querySelector(".agent-log-badge-row") as HTMLElement;
+
+ expect(block).toBeTruthy();
+ expect(badgeRow).toBeTruthy();
+ expect(getComputedStyle(block).width).toBe("100%");
+ expect(getComputedStyle(badgeRow).position).toBe("sticky");
+ expect(getComputedStyle(badgeRow).left).not.toBe("");
+ expect(getComputedStyle(badgeRow).pointerEvents).toBe("none");
+ });
+
it("includes timestamp in the badge container for tool entries", () => {
const entries = [makeEntry({ text: "Bash", type: "tool", agent: "executor" })];
const { container } = render(
);
diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
index 9c757f2985..a32ea8b831 100644
--- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx
@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import fs from "fs";
+import path from "path";
import { ExecutorStatusBar } from "../ExecutorStatusBar";
const viewportModeMock = vi.hoisted(() => ({ value: "desktop" as "desktop" | "tablet" | "mobile" }));
@@ -43,6 +45,13 @@ import { useExecutorStats } from "../../hooks/useExecutorStats";
import type { ExecutorStats } from "../../api";
const mockUseExecutorStats = useExecutorStats as ReturnType
;
+const executorStatusBarCss = fs.readFileSync(path.join(__dirname, "../ExecutorStatusBar.css"), "utf-8");
+
+function getCssRuleBlock(selector: string): string {
+ const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const match = executorStatusBarCss.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
+ return match?.[1] ?? "";
+}
/** Minimal empty task list used by tests that mock the hook. */
const emptyTasks: any[] = [];
@@ -227,6 +236,16 @@ describe("ExecutorStatusBar", () => {
expect(onOpenQuickChat).toHaveBeenCalledTimes(1);
});
+ it("keeps Quick Chat and Terminal footer launchers on the same font and color tokens", () => {
+ const launcherRule = getCssRuleBlock(".executor-status-bar__footer-launcher");
+
+ expect(launcherRule).toContain("color: inherit");
+ expect(launcherRule).toContain("font-family: var(--font-primary)");
+ expect(launcherRule).toContain("font-size: inherit");
+ expect(launcherRule).toContain("font-weight: 500");
+ expect(launcherRule).not.toMatch(/#|rgb\(/i);
+ });
+
it("omits the Quick Chat footer launcher for floating, off, and mobile modes", () => {
const { rerender } = render(
{
expect(screen.getByRole("button", { name: /toggle word wrap/i })).toBeInTheDocument();
});
+ it("switches between mobile editor layout and two-pane layout from floating modal width", async () => {
+ const originalResizeObserver = globalThis.ResizeObserver;
+ const originalWindowResizeObserver = window.ResizeObserver;
+ const observedElements: Array<{ element: Element; callback: ResizeObserverCallback }> = [];
+ const MockResizeObserver = class ResizeObserver {
+ private callback: ResizeObserverCallback;
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback;
+ }
+ observe(element: Element) {
+ observedElements.push({ element, callback: this.callback });
+ }
+ unobserve() {}
+ disconnect() {}
+ };
+ globalThis.ResizeObserver = MockResizeObserver;
+ window.ResizeObserver = MockResizeObserver;
+
+ try {
+ Object.defineProperty(window, "innerWidth", {
+ writable: true,
+ configurable: true,
+ value: 1024,
+ });
+
+ render(
+ ,
+ );
+
+ const modal = document.querySelector(".file-browser-modal") as HTMLElement;
+ expect(modal).toBeInTheDocument();
+ await waitFor(() => expect(observedElements.some((entry) => entry.element === modal)).toBe(true));
+ Object.defineProperty(modal, "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({ width: 420, height: 700, top: 0, left: 0, bottom: 700, right: 420, x: 0, y: 0, toJSON: () => ({}) }),
+ });
+
+ await act(async () => {
+ observedElements.find((entry) => entry.element === modal)?.callback([] as ResizeObserverEntry[], {} as ResizeObserver);
+ });
+
+ await waitFor(() => {
+ expect(modal).toHaveClass("file-browser-modal--narrow");
+ });
+ expect(document.querySelector(".file-browser-content.mobile.active")).not.toBeNull();
+ expect(document.querySelector(".file-browser-sidebar.mobile.active")).toBeNull();
+
+ Object.defineProperty(modal, "getBoundingClientRect", {
+ configurable: true,
+ value: () => ({ width: 980, height: 700, top: 0, left: 0, bottom: 700, right: 980, x: 0, y: 0, toJSON: () => ({}) }),
+ });
+
+ await act(async () => {
+ observedElements.find((entry) => entry.element === modal)?.callback([] as ResizeObserverEntry[], {} as ResizeObserver);
+ });
+
+ await waitFor(() => {
+ expect(modal).not.toHaveClass("file-browser-modal--narrow");
+ });
+ expect(document.querySelector(".file-browser-content.mobile")).toBeNull();
+ expect(document.querySelector(".file-browser-sidebar.mobile")).toBeNull();
+ expect(screen.getByRole("separator", { name: "Resize sidebar" })).toBeInTheDocument();
+ } finally {
+ globalThis.ResizeObserver = originalResizeObserver;
+ window.ResizeObserver = originalWindowResizeObserver;
+ }
+ });
+
it("keeps mobile close button visible and clickable", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
@@ -406,6 +479,48 @@ describe("FileBrowserModal", () => {
expect(pathRules).toContain("max-width: 50vw");
});
+ it("keeps the mobile file modal header easy to drag by touch", async () => {
+ const { loadAllAppCss } = await import("../../test/cssFixture");
+ const cssContent = loadAllAppCss();
+ const baseHeaderRules = cssContent.match(/\.file-browser-modal-header\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(baseHeaderRules).toContain("touch-action: none");
+ expect(baseHeaderRules).toContain("min-height: 48px");
+
+ function extractMobileMediaBlocks(content: string): string {
+ const blocks: string[] = [];
+ const regex = /@media[^{]*\(max-width: 768px\)[^{]*\{/g;
+ let match;
+
+ while ((match = regex.exec(content)) !== null) {
+ const startIdx = match.index + match[0].length;
+ let braceCount = 1;
+ let endIdx = startIdx;
+
+ while (braceCount > 0 && endIdx < content.length) {
+ if (content[endIdx] === "{") braceCount += 1;
+ if (content[endIdx] === "}") braceCount -= 1;
+ endIdx += 1;
+ }
+
+ if (braceCount === 0) {
+ blocks.push(content.slice(startIdx, endIdx - 1));
+ }
+ }
+
+ return blocks.join("\n");
+ }
+
+ const mobileBlock = extractMobileMediaBlocks(cssContent);
+ const mobileHeaderRules = mobileBlock.match(/\.file-browser-modal-header\s*\{([^}]*)\}/)?.[1] ?? "";
+ const mobileHandleRules = mobileBlock.match(/\.file-browser-modal-header::before\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(mobileHeaderRules).toContain("min-height: 56px");
+ expect(mobileHeaderRules).toContain("padding-block: calc(var(--space-md) + var(--space-xs)) var(--space-md)");
+ expect(mobileHandleRules).toContain("position: absolute");
+ expect(mobileHandleRules).toContain("background: color-mix(in srgb, var(--text-muted) 44%, transparent)");
+ });
+
it("closes on Escape and saves on Cmd+S", () => {
mockUseWorkspaceFileEditor.mockReturnValue({
...defaultEditorState,
diff --git a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
index e2ceb5103a..6b18d8ec1e 100644
--- a/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
+++ b/packages/dashboard/app/components/__tests__/FloatingWindow.test.tsx
@@ -45,6 +45,14 @@ describe("FloatingWindow", () => {
}
});
+ it("uses a theme-overridable gentle shadow token instead of an undefined shadow", () => {
+ const windowRule = floatingWindowCss.match(/\.floating-window\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(windowRule).toContain("--floating-window-shadow: var(--shadow-lg);");
+ expect(windowRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
+ expect(floatingWindowCss).not.toContain("var(--shadow-xl)");
+ });
+
it("can hide generic chrome and delegate dragging to a child header", () => {
render(
{
expect(container.querySelector(".modal-overlay.git-manager-modal-overlay")).toBeTruthy();
});
+ it("keeps the sidebar-launched Git Manager overlay transparent and click-through like Files", () => {
+ const css = loadAllAppCss();
+ const overlayRule = css.match(/\.modal-overlay\.git-manager-modal-overlay\s*\{([^}]*)\}/)?.[1] ?? "";
+ const panelRule = css.match(/\.modal\.gm-modal\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(overlayRule).toContain("background: transparent");
+ expect(overlayRule).toContain("backdrop-filter: none");
+ expect(overlayRule).toContain("pointer-events: none");
+ expect(panelRule).toContain("pointer-events: auto");
+ });
+
it("applies mobile keyboard CSS variables to gm-modal when keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({
diff --git a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx
index 1c6b3dbfaa..7c54aa5ea4 100644
--- a/packages/dashboard/app/components/__tests__/InsightsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/InsightsView.test.tsx
@@ -1160,7 +1160,7 @@ describe("InsightsView", () => {
render();
const toggle = screen.getByTestId("toggle-backlog-health");
- expect(toggle).toHaveTextContent("Backlog Health (1)");
+ expect(toggle).toHaveTextContent("Backlog (1)");
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(screen.getByTestId("insights-category-quality")).toBeInTheDocument();
expect(screen.getByTestId("insights-category-workflow")).toBeInTheDocument();
@@ -1280,7 +1280,7 @@ describe("InsightsView", () => {
const item = screen.getByText("Archived Insight").closest("li");
expect(item?.className).toContain("insight-item--archived");
expect(screen.getByTestId("unarchive-INS-ARCH")).toBeTruthy();
- expect(screen.getByTestId("toggle-archived-insights")).toHaveTextContent("Hide Archived");
+ expect(screen.getByTestId("toggle-archived-insights")).toHaveTextContent("Archived");
});
});
@@ -1367,7 +1367,7 @@ describe("InsightsView", () => {
expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-view\s*\{[^}]*inline-size:\s*100%;[^}]*min-inline-size:\s*0;[^}]*overflow:\s*hidden;[^}]*\}/);
expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-body\s*\{[^}]*flex-direction:\s*column;[^}]*inline-size:\s*100%;[^}]*min-width:\s*0;[^}]*min-inline-size:\s*0;[^}]*overflow:\s*hidden;[^}]*\}/);
- expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-sidebar\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-inline-size:\s*0;[^}]*border-right:\s*none;[^}]*border-bottom:\s*var\(--btn-border-width\)\s+solid\s+var\(--border\);[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*hidden;[^}]*\}/);
+ expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-sidebar\s*\{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-inline-size:\s*0;[^}]*border-right:\s*none;[^}]*border-bottom:\s*var\(--chrome-divider-width,\s*1px\)\s+solid\s+var\(--insights-divider-color\);[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*hidden;[^}]*\}/);
expect(css).toMatch(/@media[^{]*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)[^{]*\{[\s\S]*?\.insights-detail\s*\{[^}]*flex:\s*1\s+1\s+0;[^}]*min-width:\s*0;[^}]*min-inline-size:\s*0;[^}]*overflow-y:\s*auto;[^}]*\}/);
});
});
diff --git a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
index 1f40853655..a2e180546e 100644
--- a/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
+++ b/packages/dashboard/app/components/__tests__/LeftSidebarNav.test.tsx
@@ -527,7 +527,7 @@ describe("LeftSidebarNav", () => {
const itemRule = getCssRuleBlock(leftSidebarNavCss, ".left-sidebar-nav__item");
expect(itemRule).toContain("gap: var(--space-sm)");
expect(itemRule).toContain("border-radius: var(--radius-md)");
- expect(itemRule).toContain("color: var(--text-muted)");
+ expect(itemRule).toContain("color: var(--text)");
expect(itemRule).not.toMatch(/#|rgb\(/i);
});
diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx
index 8092ab0ad5..d8145d2cd6 100644
--- a/packages/dashboard/app/components/__tests__/ListView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx
@@ -1586,21 +1586,31 @@ describe("ListView", () => {
expect(mockOnNewTask).toHaveBeenCalled();
});
- it("renders + New Task as the trailing desktop sidebar control", () => {
+ it("keeps Bulk Edit, View, and + New Task together in the desktop sidebar controls", () => {
renderListView({}, { openViewOptions: false });
- const actions = document.querySelector(".list-sidebar-controls__actions");
- const actionButtons = Array.from(actions?.querySelectorAll("button") ?? []);
- expect(actionButtons.at(-1)?.textContent).toContain("+ New Task");
+ const actions = document.querySelector(".list-sidebar-controls .list-action-cluster");
+ const actionButtons = Array.from(actions?.querySelectorAll("button") ?? []).map((button) => button.textContent);
+ expect(actionButtons).toEqual(["Bulk Edit", "View", "+ New Task"]);
});
- it("renders + New Task as the trailing mobile toolbar control", () => {
+ it("keeps the primary list action cluster on one physical row when the pane narrows", () => {
+ const css = readFileSync("app/components/ListView.css", "utf8");
+ const actionClusterRule = css.match(/\.list-action-cluster,\s*\n\.list-sidebar-controls__actions\s*\{[^}]*\}/)?.[0] ?? "";
+
+ expect(actionClusterRule).toContain("flex-wrap: nowrap");
+ expect(actionClusterRule).toContain("inline-size: max-content");
+ expect(actionClusterRule).toContain("min-width: max-content");
+ expect(actionClusterRule).toContain("overflow-x: auto");
+ });
+
+ it("keeps Bulk Edit, View, and + New Task together in the mobile toolbar controls", () => {
const viewportSpy = mockMobileViewport();
renderListView({}, { openViewOptions: false });
- const toolbar = document.querySelector(".list-toolbar");
- const toolbarButtons = Array.from(toolbar?.querySelectorAll("button") ?? []);
- expect(toolbarButtons.at(-1)?.textContent).toContain("+ New Task");
+ const actions = document.querySelector(".list-toolbar .list-action-cluster");
+ const actionButtons = Array.from(actions?.querySelectorAll("button") ?? []).map((button) => button.textContent);
+ expect(actionButtons).toEqual(["Bulk Edit", "View", "+ New Task"]);
viewportSpy.mockRestore();
});
diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
index 24158c87fe..0c7d00c8f1 100644
--- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
@@ -1,12 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
+import { readFileSync } from "node:fs";
import { NewTaskModal } from "../NewTaskModal";
import type { Task, Column } from "@fusion/core";
import { checkDuplicateTasks, type BoardWorkflowsPayload } from "../../api";
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
+const newTaskModalCss = readFileSync("app/components/NewTaskModal.css", "utf8");
+
// Mock lucide-react
vi.mock("lucide-react", () => ({
Sparkles: () => null,
@@ -1477,6 +1480,16 @@ describe("NewTaskModal", () => {
expect(panel).not.toBeNull();
});
+ it("keeps the floating window touch-draggable with theme-controlled shadow", () => {
+ const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
+ const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
+ expect(headerRule).toContain("touch-action: none;");
+ expect(headerRule).toContain("min-height: 48px;");
+ expect(newTaskModalCss).not.toContain("var(--shadow-xl)");
+ });
+
it("still closes via the header close button (X)", async () => {
const onClose = vi.fn();
renderNewTaskModal({ onClose });
diff --git a/packages/dashboard/app/components/__tests__/RightDock.test.tsx b/packages/dashboard/app/components/__tests__/RightDock.test.tsx
index 881ae80554..d259c26c58 100644
--- a/packages/dashboard/app/components/__tests__/RightDock.test.tsx
+++ b/packages/dashboard/app/components/__tests__/RightDock.test.tsx
@@ -5,6 +5,8 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { RightDock, RIGHT_DOCK_VIEW_STORAGE_KEY, RIGHT_DOCK_WIDTH_STORAGE_KEY } from "../RightDock";
import { RightDockExpandModal } from "../RightDockExpandModal";
import { useRightDockController, type RightDockControllerInput } from "../useRightDockController";
+import { DOCK_FILES_CURRENT_KEY } from "../DockFilesView";
+import { setScopedItem } from "../../utils/projectStorage";
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal();
@@ -72,6 +74,16 @@ describe("RightDock", () => {
expect(rightDockCss).not.toContain("border-bottom: thin solid var(--border);");
});
+ it("keeps the right-dock pop-out touch-draggable with theme-controlled shadow", () => {
+ const panelRule = rightDockCss.match(/\.right-dock-expand-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
+ const headerRule = rightDockCss.match(/\.right-dock-expand-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
+ expect(headerRule).toContain("touch-action: none;");
+ expect(headerRule).toContain("min-height: 44px;");
+ expect(rightDockCss).not.toContain("var(--shadow-xl)");
+ });
+
it("renders Files by default and restores the persisted inline view on remount", () => {
const { unmount } = render();
@@ -413,4 +425,48 @@ describe("RightDock", () => {
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();
});
+
+ it("routes Files expand to the file browser modal when an individual file is selected", () => {
+ const openFileInBrowser = vi.fn();
+ setScopedItem(DOCK_FILES_CURRENT_KEY, "readme.md", "project-1");
+ const controllerInput = {
+ active: true,
+ projectId: "project-1",
+ addToast: vi.fn(),
+ settingsLoaded: true,
+ researchReadinessVersion: 0,
+ tasks: [],
+ workflowSteps: [],
+ subscribePluginEvents: () => () => {},
+ openDetailTask: vi.fn(),
+ openFileInBrowser,
+ openSettings: vi.fn(),
+ onSendSelectionToTask: vi.fn(),
+ onCreateTaskFromInsight: vi.fn(),
+ onNavigateToMission: vi.fn(),
+ onTaskCreated: vi.fn(),
+ workflowStepNameLookup: new Map(),
+ prAuthAvailable: false,
+ autoMerge: false,
+ visibilityOptions: {},
+ footerVisible: false,
+ } as unknown as RightDockControllerInput;
+
+ function Harness() {
+ const controller = useRightDockController(controllerInput);
+ return (
+ <>
+ {controller.dock}
+ {controller.modal}
+ >
+ );
+ }
+
+ render();
+
+ fireEvent.click(screen.getByTestId("right-dock-expand"));
+
+ expect(openFileInBrowser).toHaveBeenCalledWith("readme.md", { workspace: "project" });
+ expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();
+ });
});
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
index bd38e5c4a7..fb3a2dde7c 100644
--- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx
@@ -2,6 +2,7 @@
FNXC:DashboardTests 2026-06-14-08:31:
FN-6441 rescued this orphaned component test after standalone dashboard-app execution passed without assertion, timeout, or source-code changes. Keep the terminal modal coverage in app backfill because keyboard, session, and mobile terminal regressions are user-facing and should not remain skip-listed.
*/
+import { readFileSync } from "node:fs";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { TerminalModal, _resetInitialViewportHeight, ctrlChar, altChar } from "../TerminalModal";
@@ -17,6 +18,8 @@ import * as useTerminalModule from "../../hooks/useTerminal";
import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions";
import * as apiModule from "../../api";
+const terminalModalCss = readFileSync("app/components/TerminalModal.css", "utf8");
+
function splitFontFamilies(stack: string): string[] {
return stack
.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)
@@ -340,6 +343,16 @@ describe("TerminalModal", () => {
});
});
+ it("keeps the floating terminal touch-draggable with theme-controlled shadow", () => {
+ const panelRule = terminalModalCss.match(/\.modal\.terminal-modal\.terminal-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
+ const headerRule = terminalModalCss.match(/\.terminal-header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
+
+ expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
+ expect(headerRule).toContain("touch-action: none;");
+ expect(headerRule).toContain("min-height: 48px;");
+ expect(terminalModalCss).not.toContain("var(--shadow-xl)");
+ });
+
it("keeps mobile terminal on the full-screen modal path without docked or floating controls", async () => {
const previousInnerWidth = window.innerWidth;
const previousOntouchstart = window.ontouchstart;
diff --git a/packages/dashboard/app/components/useRightDockController.tsx b/packages/dashboard/app/components/useRightDockController.tsx
index ebf7c4cdc0..12a14c5607 100644
--- a/packages/dashboard/app/components/useRightDockController.tsx
+++ b/packages/dashboard/app/components/useRightDockController.tsx
@@ -4,6 +4,8 @@ import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplica
import type { ToastType } from "../hooks/useToast";
import type { DetailTaskTab } from "../hooks/useModalManager";
import { fetchTaskDetail } from "../api";
+import { getScopedItem } from "../utils/projectStorage";
+import { DOCK_FILES_CURRENT_KEY } from "./DockFilesView";
import { TaskCard } from "./TaskCard";
import { RightDock, persistRightDockOpen, readStoredRightDockOpen } from "./RightDock";
import { RightDockExpandModal } from "./RightDockExpandModal";
@@ -70,12 +72,27 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
Popping a view out CLOSES the right dock but KEEPS the floating modal open. The modal is independent of dock open state (see expandedView note above), so collapsing the dock on pop-out gives the user the full-width app behind the movable, non-blocking modal. Clearing the pop-out (viewKey null) leaves the dock as-is.
*/
const handleExpand = useCallback((viewKey: OverflowViewKey | null) => {
+ /*
+ FNXC:RightDockFiles 2026-06-23-23:38:
+ If Files is showing an individual file, Expand should open the existing FileBrowserModal at that file instead of the generic right-dock expanded panel. The file modal is the shared movable/resizable file surface and keeps its transparent, non-blurring FloatingWindow backdrop; an empty Files view still expands to the two-pane browser.
+ */
+ if (viewKey === "files") {
+ const currentFile = getScopedItem(DOCK_FILES_CURRENT_KEY, input.projectId);
+ if (currentFile) {
+ input.openFileInBrowser(currentFile, { workspace: "project" });
+ setOpen(false);
+ persistRightDockOpen(false);
+ setExpandedView(null);
+ return;
+ }
+ }
+
setExpandedView(viewKey);
if (viewKey) {
setOpen(false);
persistRightDockOpen(false);
}
- }, []);
+ }, [input]);
useEffect(() => {
if (!input.active) setExpandedView(null);