feat(FN-6950): polish dashboard responsive chrome

This commit is contained in:
gsxdsm
2026-06-22 23:58:38 -07:00
parent 496167c3d4
commit 2d038e7ffa
30 changed files with 517 additions and 148 deletions

View File

@@ -165,6 +165,12 @@ describe("Agent CSS classes", () => {
expect(roleFocusBlock).toContain("box-shadow: var(--focus-ring-strong)");
});
it("should use provider icons instead of decorative role glyphs in the create-agent role picker", () => {
expect(newAgentDialogContent).toContain("<ProviderIcon provider={selectedModelProvider} size=\"sm\" />");
expect(newAgentDialogContent).not.toMatch(/icon:\s*"[⊕▶⊙⊞◷⎔✦]"/);
expect(newAgentDialogContent).not.toContain("selectedRole?.icon");
});
it("should keep the create-agent empty-state action copy", () => {
expect(agentEmptyStateContent).toContain("Create Agent");
});

View File

@@ -16,6 +16,18 @@ function extractRuleBody(source: string, selector: string): string {
return match?.[1] ?? "";
}
function extractGroupedRuleBody(source: string, selector: string): string {
const sourceWithoutComments = source.replace(/\/\*[\s\S]*?\*\//g, "");
const match = [...sourceWithoutComments.matchAll(/(^|})\s*([^{}]+)\s*\{([\s\S]*?)\}/g)].find(([, , selectors]) =>
selectors
.split(",")
.map((part) => part.trim())
.includes(selector),
);
expect(match, `${selector} grouped rule should exist in LeftSidebarNav.css`).not.toBeNull();
return match?.[3] ?? "";
}
describe("left sidebar active accent CSS", () => {
/**
* FNXC:DashboardStyling 2026-06-21-11:16:
@@ -23,7 +35,7 @@ describe("left sidebar active accent CSS", () => {
*/
it("uses the theme accent token for active item and resize handle styling", () => {
const source = readLeftSidebarCss();
const activeItemBody = extractRuleBody(source, ".left-sidebar-nav__item--active");
const activeItemBody = extractGroupedRuleBody(source, ".left-sidebar-nav__item--active");
expect(activeItemBody).toContain("var(--accent)");
expect(activeItemBody).not.toContain("var(--todo)");

View File

@@ -133,12 +133,34 @@
margin-left: auto;
}
/*
FNXC:TaskDetailChat 2026-06-23-23:55:
Task-detail chat output blocks can be long enough that the executor/reviewer label scrolls out of view.
Keep each block full-width and float the role/timestamp badge as a sticky overlay on the left so the visible content always has role context without reserving a permanent label column.
*/
.agent-log-badge-row {
position: sticky;
top: var(--space-xs);
left: var(--space-xs);
z-index: 2;
display: inline-flex;
align-items: center;
width: max-content;
max-width: calc(100% - var(--space-md));
margin: 0 0 var(--space-xs) var(--space-xs);
padding: 2px var(--space-xs);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--surface) 88%, transparent);
box-shadow: 0 1px 4px color-mix(in srgb, var(--shadow-color, #000) 12%, transparent);
pointer-events: none;
white-space: nowrap;
}
.agent-log-tool {
position: relative;
width: 100%;
box-sizing: border-box;
color: var(--accent);
margin: var(--space-xs) 0;
padding: var(--space-xs) var(--space-sm);
@@ -147,6 +169,9 @@
}
.agent-log-tool-result {
position: relative;
width: 100%;
box-sizing: border-box;
color: var(--color-success);
margin: calc(var(--space-xs) / 2) 0;
padding: var(--space-xs) var(--space-sm);
@@ -156,6 +181,9 @@
}
.agent-log-tool-error {
position: relative;
width: 100%;
box-sizing: border-box;
color: var(--color-error);
margin: calc(var(--space-xs) / 2) 0;
padding: var(--space-xs) var(--space-sm);
@@ -228,11 +256,15 @@
.agent-log-text {
display: block;
position: relative;
width: 100%;
color: var(--text);
}
.agent-log-thinking {
display: block;
position: relative;
width: 100%;
font-style: italic;
color: var(--text);
}

View File

@@ -26,7 +26,7 @@ FNXC:RightDockFiles 2026-06-22-23:30:
The compact dock Files view and the popped-out (expand) Files view are SEPARATE component instances (one renders in the dock body, the other inside RightDockExpandModal). The currently-viewed file lived in each instance's local `selectedFile` state, so popping out always opened with no file selected.
Share the current-file path through scoped localStorage (`kb-dashboard-dock-files-current`, keyed per project via projectStorage). Selecting/clearing a file writes the key; on mount each instance reads it so the expand opens the SAME file the dock was showing. A `storage` listener keeps both instances live-synced when the other tab/instance changes selection.
*/
const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current";
export const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current";
/*
FNXC:RightDockFiles 2026-06-22-00:00:

View File

@@ -71,8 +71,14 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
padding: 0 var(--space-xs);
border: none;
background: transparent;
color: var(--text-muted);
font: inherit;
/*
* FNXC:FooterChrome 2026-06-23-00:20:
* Quick Chat and Terminal are peer footer launchers. Pin both to the footer's compact UI font and color token so switching the launcher location does not make one control read heavier or dimmer than the other.
*/
color: inherit;
font-family: var(--font-primary);
font-size: inherit;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;

View File

@@ -32,8 +32,10 @@
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
min-height: 48px;
cursor: grab;
user-select: none;
touch-action: none;
}
.file-browser-modal-header:active {
@@ -585,9 +587,28 @@ Narrow Files windows use the same single-pane list/editor behavior as mobile eve
}
.file-browser-modal-header {
position: relative;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-sm);
min-height: 56px;
padding-block: calc(var(--space-md) + var(--space-xs)) var(--space-md);
}
/*
FNXC:FileBrowser 2026-06-23-23:25:
On phones the full-screen Files modal still uses the header as its drag handle, but the title row can wrap and the action controls consume much of the top bar. Preserve a large touch-safe grab area with touch-action:none and add a subtle handle marker so dragging is discoverable without adding a second toolbar.
*/
.file-browser-modal-header::before {
content: "";
position: absolute;
top: var(--space-xs);
left: 50%;
width: calc(var(--space-xl) + var(--space-sm));
height: calc(var(--space-xs) * 0.75);
transform: translateX(-50%);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--text-muted) 44%, transparent);
}
.file-browser-header-title {

View File

@@ -83,7 +83,7 @@ export function FileBrowserModal({
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [viewportMobile, setViewportMobile] = useState(false);
const [modalNarrow, setModalNarrow] = useState(false);
const [modalWidth, setModalWidth] = useState<number | null>(null);
const [mobileView, setMobileView] = useState<"list" | "editor">("list");
const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH);
const [showLineNumbers, setShowLineNumbers] = useState(false);
@@ -128,25 +128,33 @@ export function FileBrowserModal({
/*
FNXC:FileBrowser 2026-06-22-17:25:
The Files floating window can be resized narrower than the desktop two-pane layout while the browser viewport is still desktop-sized. Mirror Chat's ResizeObserver-driven responsive mode: once the modal itself is at mobile width, switch to the list/editor single-pane flow and hide the sidebar after a file opens.
FNXC:FileBrowser 2026-06-23-23:45:
The Files modal layout should be responsive to its own floating-window width: wide modals show the two-pane browser/editor split, narrow modals show the mobile list/editor flow. Viewport width is only a pre-measurement fallback so a widened modal can always return to the split view.
*/
useLayoutEffect(() => {
const element = modalRef.current;
if (!element || typeof ResizeObserver === "undefined") {
if (!element) {
return;
}
const update = () => {
const measuredWidth = element.getBoundingClientRect().width || element.clientWidth || window.innerWidth;
setModalNarrow(measuredWidth <= MOBILE_BREAKPOINT);
setModalWidth(measuredWidth);
};
update();
if (typeof ResizeObserver === "undefined") {
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, []);
const isMobile = viewportMobile || modalNarrow;
const isMobile = modalWidth === null ? viewportMobile : modalWidth <= MOBILE_BREAKPOINT;
useEffect(() => {
if (!selectedFile) {

View File

@@ -15,6 +15,7 @@ FNXC:FloatingWindow 2026-06-22-20:45:
Floating panel positioned by state-driven inline `left/top/width/height` and stacked by inline `z-index`. min/max keep the panel usable and on-screen. `resize: none` because resizing is handled by the corner/edge handles. `pointer-events: auto` re-enables interaction on the panel only.
*/
.floating-window {
--floating-window-shadow: var(--shadow-lg);
position: fixed;
display: flex;
flex-direction: column;
@@ -26,7 +27,11 @@ Floating panel positioned by state-driven inline `left/top/width/height` and sta
background: var(--surface);
border: thin solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
/*
FNXC:FloatingWindow 2026-06-23-23:25:
Floating modals need a gentle, theme-controlled drop shadow. Use a local token with the app's existing shadow fallback instead of the undefined --shadow-xl so themes can soften, strengthen, or remove modal elevation intentionally.
*/
box-shadow: var(--floating-window-shadow, var(--shadow-lg));
color: var(--text);
resize: none;
pointer-events: auto;

View File

@@ -487,6 +487,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
FNXC:Insights 2026-06-23-19:20:
The refresh action must be icon-only in the Insights header. Keeping the visible label out of this button preserves room for the title and neighboring controls while aria-label/title retain the accessible command name.
FNXC:Insights 2026-06-23-00:23:
Insights header filter chips need compact visible labels. Keep the descriptive accessibility copy, but show Backlog instead of Backlog Health and Archived instead of Show Archived/Hide Archived.
*/}
<ViewHeader
icon={Sparkles}
@@ -504,7 +507,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
title={BACKLOG_HEALTH_TITLE_PREFIXES.join(", ")}
>
<Activity size={14} />
{backlogHealthOnly ? t("insights.allInsights", "All Insights") : t("insights.backlogHealth", "Backlog Health")} <span>({backlogHealthCount})</span>
{backlogHealthOnly ? t("insights.allInsights", "All Insights") : t("insights.backlogHealth", "Backlog")} <span>({backlogHealthCount})</span>
</button>
)}
{onClose && (
@@ -525,7 +528,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
data-testid="toggle-archived-insights"
>
<Archive size={14} />
{showArchived ? t("insights.hideArchivedLabel", "Hide Archived") : t("insights.showArchivedLabel", "Show Archived ({{count}})", { count: archivedCount })}
{showArchived ? t("insights.hideArchivedLabel", "Archived") : t("insights.showArchivedLabel", "Archived ({{count}})", { count: archivedCount })}
</button>
)}
<button

View File

@@ -17,6 +17,7 @@ The border between left sidebar and main content should be invisible while the s
min-height: 0;
background: var(--surface);
color: var(--text);
font-family: var(--font-primary);
}
/*
@@ -142,8 +143,17 @@ The secondary section has no divider and no extra top padding, so the gap across
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--text-muted);
font: inherit;
color: var(--text);
/*
FNXC:NavigationTypography 2026-06-23-23:43:
Sidebar destinations should read like app chrome instead of light body copy, but not as bold labels.
Use the same base text color as headers with medium-normal weight so the nav matches the app theme without looking heavy.
*/
font-family: var(--font-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: var(--line-height-tight);
letter-spacing: 0;
text-align: left;
cursor: pointer;
transition:

View File

@@ -17,7 +17,9 @@
background: var(--surface);
}
/* FNXC:ListView 2026-06-23-20:15: No divider between the controls and quick-add. The controls row carries actions-count-action layout; quick-add directly follows it as one continuous surface. */
/* FNXC:ListView 2026-06-23-23:42: No divider between the controls and quick-add. The controls row now carries action groups only; the aggregate top task count was removed while contextual section counts remain lower in the list.
FNXC:ListView 2026-06-23-23:55: Bulk Edit, View, and New Task are one no-wrap action cluster so the primary list actions stay visually together instead of splitting across opposite edges or separate wrapped lines.
FNXC:ListView 2026-06-23-00:25: The primary action cluster must stay on one physical row even when the list pane narrows; preserve max-content width and let the cluster scroll horizontally instead of wrapping individual actions onto separate lines. */
.list-sidebar-controls {
display: flex;
flex-direction: column;
@@ -27,44 +29,44 @@
border-bottom: 0;
}
/*
FNXC:ListView 2026-06-23-20:15:
The list toolbar uses a stable three-part row: left controls, centered filtered task count, and right create action. Equal flex groups keep the count visually between button groups while flex-wrap prevents overflow on narrow split-pane widths.
*/
.list-sidebar-controls__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: var(--space-sm);
flex-wrap: wrap;
}
.list-action-cluster,
.list-sidebar-controls__actions {
display: flex;
align-items: center;
justify-content: flex-start;
flex: 1 1 0;
flex-wrap: wrap;
flex: 0 1 auto;
flex-wrap: nowrap;
gap: var(--space-xs);
inline-size: max-content;
min-width: max-content;
max-width: 100%;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
}
.list-action-cluster::-webkit-scrollbar,
.list-sidebar-controls__actions::-webkit-scrollbar {
display: none;
}
.list-action-cluster > .btn,
.list-sidebar-controls__actions > .btn {
flex: 0 0 auto;
}
.list-sidebar-controls__actions--end {
justify-content: flex-end;
}
.list-sidebar-controls__count {
flex: 0 1 auto;
min-width: 0;
color: var(--text-muted);
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
FNXC:ListView 2026-06-22-23:30:
View options was oversized (full-width stacked button). Render it as a compact icon+label btn-sm consistent with its row-mates.
@@ -90,6 +92,11 @@ View options was oversized (full-width stacked button). Render it as a compact i
margin-left: auto;
}
.list-action-cluster .list-new-task-action,
.list-sidebar-controls__actions .list-new-task-action {
margin-left: 0;
}
.list-sidebar-summary-chips {
display: flex;
flex-wrap: wrap;
@@ -128,25 +135,6 @@ View options was oversized (full-width stacked button). Render it as a compact i
gap: var(--space-xs);
}
.list-stats {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
/*
FNXC:ListView 2026-06-22-23:30:
Compact count for the single header row: smaller, dimmer, and non-dominant so the action group reads as primary. min-width:0 lets it truncate before forcing the row to wrap.
*/
.list-stats--compact {
margin: 0;
min-width: 0;
font-size: calc(var(--space-xs) * 2.5);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Section expand/collapse controls */
.list-section-controls {
display: flex;
@@ -158,11 +146,6 @@ Compact count for the single header row: smaller, dimmer, and non-dominant so th
white-space: nowrap;
}
.list-stats-hidden {
color: var(--text-dim);
font-style: italic;
}
.list-clear-column-filter-btn {
margin-left: var(--space-sm);
}
@@ -968,10 +951,6 @@ In the split sidebar the title cell must allow the title to wrap to two lines (h
padding: var(--space-sm) var(--space-md);
}
.list-stats {
order: 1;
}
.list-column-toggle {
order: 3;
margin-left: auto;
@@ -1176,14 +1155,6 @@ In the split sidebar the title cell must allow the title to wrap to two lines (h
gap: var(--space-sm);
}
.list-stats {
width: 100%;
order: 10;
text-align: center;
margin-left: 0;
font-size: 11px;
}
.list-selection-stats {
width: 100%;
justify-content: center;

View File

@@ -1810,6 +1810,28 @@ export function ListView({
</div>
);
const renderPrimaryActionCluster = () => (
<div className="list-action-cluster" data-testid="list-primary-action-cluster">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls={isMobile ? "list-view-options-panel-mobile" : "list-view-options-panel"}
>
<Columns3 size={14} />
{t("listView.viewOptions", "View")}
</button>
{onNewTask ? (
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
{t("listView.newTask", "+ New Task")}
</button>
) : null}
</div>
);
const renderBulkEditToolbars = () => (
<>
<div className="bulk-edit-toolbar">
@@ -1904,29 +1926,8 @@ export function ListView({
{isMobile && (
<>
<div className="list-toolbar">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
{renderWorkflowSelector()}
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel-mobile"
>
<Columns3 size={14} />
{t("listView.viewOptions", "View")}
</button>
{onNewTask ? (
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
{t("listView.newTask", "+ New Task")}
</button>
) : null}
<div className="list-stats">
{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 })}
</div>
{renderPrimaryActionCluster()}
</div>
{viewOptionsOpen ? (
<div className="list-toolbar-mobile-options">{renderViewOptionsPanel("list-view-options-panel-mobile")}</div>
@@ -1957,38 +1958,13 @@ export function ListView({
{!isMobile && (
<aside className="list-sidebar-controls" aria-label={t("listView.listControlsLabel", "List controls")}>
{/*
FNXC:ListView 2026-06-23-20:15:
Desktop list controls keep one compact row above quick-add: Bulk Edit/View on the left, the filtered task count centered between button groups, and New Task on the right. The quick-add area directly follows this row with no dividing line above it.
FNXC:ListView 2026-06-23-23:42:
The List view top controls should not show the aggregate task count. Keep only action groups and state chips near quick-add; section/drop-zone counts remain lower in the list where they are contextual.
*/}
<div className="list-sidebar-controls__header">
{renderWorkflowSelector()}
<div className="list-sidebar-controls__toolbar">
<div className="list-sidebar-controls__actions">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel"
>
<Columns3 size={14} />
{t("listView.viewOptions", "View")}
</button>
</div>
<span className="list-sidebar-controls__count">
{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 })}
</span>
<div className="list-sidebar-controls__actions list-sidebar-controls__actions--end">
{onNewTask ? (
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
{t("listView.newTask", "+ New Task")}
</button>
) : null}
</div>
{renderPrimaryActionCluster()}
</div>
<div className="list-sidebar-summary-chips">
{selectedColumn ? (

View File

@@ -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 {

View File

@@ -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)}
>
<span className="agent-role-option-icon">{r.icon}</span>
<span className="agent-role-option-icon" aria-hidden="true">
<ProviderIcon provider={selectedModelProvider} size="sm" />
</span>
<span className="agent-role-option-label">{getRoleLabel(r.value)}</span>
</button>
))}
@@ -740,7 +747,7 @@ export function NewAgentDialog({
</div>
<div className="agent-dialog-summary-row">
<span className="agent-dialog-summary-row-label">{t("agents.fieldRole", "Role")}</span>
<span>{selectedRole?.icon} {selectedRole ? getRoleLabel(selectedRole.value) : ""}</span>
<span>{selectedRole ? getRoleLabel(selectedRole.value) : ""}</span>
</div>
{selectedReportsToId && (
<div className="agent-dialog-summary-row">

View File

@@ -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 {

View File

@@ -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));
}
/*

View File

@@ -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;
}
/*

View File

@@ -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 {

View File

@@ -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(<AgentLogViewer entries={entries} loading={false} />);
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(<AgentLogViewer entries={entries} loading={false} />);

View File

@@ -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<typeof vi.fn>;
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(
<ExecutorStatusBar

View File

@@ -286,6 +286,79 @@ describe("FileBrowserModal", () => {
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(
<FileBrowserModal
initialWorkspace="project"
initialFile="file1.ts"
isOpen={true}
onClose={mockOnClose}
/>,
);
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,

View File

@@ -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(
<FloatingWindow

View File

@@ -313,6 +313,17 @@ describe("GitManagerModal", () => {
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({

View File

@@ -1160,7 +1160,7 @@ describe("InsightsView", () => {
render(<InsightsView {...defaultProps} />);
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;[^}]*\}/);
});
});

View File

@@ -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);
});

View File

@@ -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();
});

View File

@@ -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 });

View File

@@ -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<typeof import("../../api")>();
@@ -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(<RightDock open={true} renderProps={renderProps} />);
@@ -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<string, string>(),
prAuthAvailable: false,
autoMerge: false,
visibilityOptions: {},
footerVisible: false,
} as unknown as RightDockControllerInput;
function Harness() {
const controller = useRightDockController(controllerInput);
return (
<>
{controller.dock}
{controller.modal}
</>
);
}
render(<Harness />);
fireEvent.click(screen.getByTestId("right-dock-expand"));
expect(openFileInBrowser).toHaveBeenCalledWith("readme.md", { workspace: "project" });
expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();
});
});

View File

@@ -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;

View File

@@ -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);