feat(FN-3027): add AgentLogViewer, NewAgentDialog improvements, and AgentDe

This merge adds research settings to the settings modal and dashboard (FN-2839, FN-3029), implements a dashboard font scale setting with persistence and documentation (FN-3027), refactors AgentDetailView with a new AgentLogViewer CSS module (FN-2839), and improves NewAgentDialog with custom model dr

Fusion-Task-Id: FN-3027
This commit is contained in:
Fusion
2026-04-30 19:42:05 -07:00
committed by gsxdsm
parent 097b4a7a47
commit 892f6bde68
19 changed files with 502 additions and 75 deletions

View File

@@ -172,7 +172,7 @@ function AppInner() {
const effectiveProjects = isRemote && remoteData.projects.length > 0 ? remoteData.projects : projects;
// Theme management - required before useViewState
const { themeMode, colorTheme, setThemeMode, setColorTheme } = useTheme();
const { themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct } = useTheme();
// Background AI sessions - required before useModalManager
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
@@ -1046,7 +1046,7 @@ function AppInner() {
}}
taskOperations={{ moveTask, deleteTask, mergeTask, retryTask, resetTask, duplicateTask }}
deepLink={{ handleDetailClose }}
settings={{ prAuthAvailable, themeMode, colorTheme, setThemeMode, setColorTheme }}
settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }}
onSettingsClose={() => {
modalManager.closeSettings();
setResearchReadinessVersion((current) => current + 1);

View File

@@ -66,8 +66,10 @@ interface AppModalsProps {
prAuthAvailable: boolean;
themeMode: ThemeMode;
colorTheme: ColorTheme;
dashboardFontScalePct: number;
setThemeMode: (mode: ThemeMode) => void;
setColorTheme: (theme: ColorTheme) => void;
setDashboardFontScalePct: (scalePct: number) => void;
};
/** Optional override for the settings modal close handler. When provided, this is called instead of modalManager.closeSettings. */
onSettingsClose?: () => void;
@@ -187,6 +189,8 @@ export function AppModals({
colorTheme={settings.colorTheme}
onThemeModeChange={settings.setThemeMode}
onColorThemeChange={settings.setColorTheme}
dashboardFontScalePct={settings.dashboardFontScalePct}
onDashboardFontScaleChange={settings.setDashboardFontScalePct}
onReopenOnboarding={onReopenOnboarding}
/>
</Suspense>

View File

@@ -618,6 +618,40 @@ html .column.drag-over * {
margin-bottom: var(--space-md);
}
.theme-font-size-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.theme-font-size-btn {
align-items: center;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
display: inline-flex;
font-size: 13px;
font-weight: 500;
justify-content: center;
min-height: calc(var(--space-2xl) + var(--space-sm));
padding: var(--space-sm) var(--space-md);
transition: all var(--transition-fast);
}
.theme-font-size-btn:hover {
border-color: var(--todo);
color: var(--text);
}
.theme-font-size-btn.active {
background: color-mix(in srgb, var(--todo) 14%, transparent);
border-color: var(--todo);
color: var(--text);
}
.theme-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -1276,4 +1310,8 @@ html .column.drag-over * {
.theme-selector {
padding: 0 14px 14px;
}
.theme-font-size-toggle {
grid-template-columns: 1fr;
}
}

View File

@@ -42,6 +42,26 @@
visibility: hidden;
}
/* Always-mounted invisible input. Focused inside the FAB click gesture
to claim the iOS soft keyboard before the real composer input renders.
Must be focusable (no `display: none`, no `visibility: hidden`) and big
enough that iOS treats focus as a real intent — so we tuck it offscreen
instead of hiding it. `font-size: 16px` defeats the iOS focus-zoom. */
.quick-chat-stealth-input {
position: fixed;
bottom: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
margin: 0;
border: 0;
opacity: 0;
pointer-events: none;
font-size: 16px;
z-index: -1;
}
/* Position set via inline style from useDraggable */
.quick-chat-panel {
--quick-chat-min-width: 280px;
@@ -210,6 +230,13 @@
text-overflow: ellipsis;
}
/* Icon-only fallback when the model name is too long for the header. */
.quick-chat-model-tag--icon {
max-width: none;
padding: var(--space-xs);
justify-content: center;
}
.quick-chat-panel-header-actions {
--quick-chat-header-control-size: calc(var(--space-lg) + var(--space-sm) + var(--space-xs));
@@ -610,13 +637,19 @@
/* === Quick Chat Mobile (FN: full-screen) =================================== */
@media (max-width: 768px) {
.quick-chat-panel {
/* Full-screen sheet that ignores drag position on mobile. The inline
style from useDraggable supplies left/top, but our overrides win via
!important so the user sees a proper modal-style sheet. */
/* Full-screen sheet that ignores drag position on mobile. The
JSX-level style only emits right/bottom on desktop now, but we
still pin every edge with !important here so any stray inline
value (or a future regression) cannot pull the panel off-screen.
`top: 0` is intentional — when the iOS keyboard opens we just
shrink height (via --vv-height), the panel does not translate
so the header stays in place. */
position: fixed !important;
inset: 0 !important;
left: 0 !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
height: 100dvh !important;

View File

@@ -3,10 +3,10 @@ import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
@@ -16,12 +16,14 @@ import type { Components } from "react-markdown";
import { Eye, EyeOff, MessageSquare, Paperclip, Plus, Send, Square, Wrench, X } from "lucide-react";
import { fetchModels, type Agent, type ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
import { AgentMentionPopup } from "./AgentMentionPopup";
import { FN_AGENT_ID, useQuickChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useQuickChat";
import { useAgents } from "../hooks/useAgents";
import { FileMentionPopup } from "./FileMentionPopup";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useViewportMode } from "../hooks/useViewportMode";
interface PendingAttachment {
file: File;
@@ -800,15 +802,13 @@ export function QuickChatFAB({
}
: setInternalOpen;
const { keyboardOverlap, viewportHeight, keyboardOpen } = useMobileKeyboard({ enabled: isOpen });
const keyboardPanelStyle: CSSProperties =
keyboardOpen
? ({
"--keyboard-overlap": `${keyboardOverlap}px`,
...(viewportHeight !== null ? { "--vv-height": `${viewportHeight}px` } : {}),
} as CSSProperties)
: {};
// We still consume keyboardOpen for layout decisions outside the panel,
// but the high-frequency --vv-offset-top / --vv-height tracking is set
// directly on the panel DOM in a layout effect below — going through
// React state introduces a per-event reconciliation lag that the human
// eye reads as jank while the iOS keyboard is animating in.
useMobileKeyboard({ enabled: isOpen });
const viewportMode = useViewportMode();
const [chatMode, setChatMode] = useState<"agent" | "model">("agent");
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
@@ -894,6 +894,43 @@ export function QuickChatFAB({
const fileInputRef = useRef<HTMLInputElement | null>(null);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const shouldAutoFocusComposerRef = useRef(false);
// Always-mounted offscreen input used to claim the iOS soft keyboard
// synchronously inside the FAB click gesture, before the real composer
// input has rendered (or while it is still `disabled` waiting for the
// session). Focus is transferred to the real input once it is enabled —
// iOS keeps the keyboard up across that transfer.
const stealthInputRef = useRef<HTMLInputElement | null>(null);
// Mirror visualViewport.height onto the panel as --vv-height directly,
// bypassing React state. The panel just shrinks when the iOS keyboard
// opens — top stays at 0 so the header remains visible.
useLayoutEffect(() => {
if (!isOpen) return;
if (typeof window === "undefined" || !window.visualViewport) return;
const panel = panelRef.current;
if (!panel) return;
const vv = window.visualViewport;
let frame = 0;
const apply = () => {
frame = 0;
panel.style.setProperty("--vv-height", `${vv.height}px`);
panel.style.setProperty("--vv-offset-top", `${vv.offsetTop || 0}px`);
};
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(apply);
};
apply();
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
};
}, [isOpen]);
const resolvedModelSelection = selectedModel || configuredDefaultModelSelection;
@@ -1082,8 +1119,18 @@ export function QuickChatFAB({
const activeElement = document.activeElement;
const panelContainsFocus = activeElement ? panelRef.current?.contains(activeElement) : false;
const isBodyFocused = activeElement === document.body;
const stealthIsFocused = activeElement === stealthInputRef.current;
if (!panelContainsFocus && !isBodyFocused) {
if (!panelContainsFocus && !isBodyFocused && !stealthIsFocused) {
shouldAutoFocusComposerRef.current = false;
return;
}
// When the stealth input is currently holding the iOS keyboard, transfer
// focus synchronously — going through requestAnimationFrame breaks the
// keyboard handoff on Safari and the keyboard dismisses.
if (stealthIsFocused) {
input.focus({ preventScroll: true });
shouldAutoFocusComposerRef.current = false;
return;
}
@@ -1522,11 +1569,32 @@ export function QuickChatFAB({
didDragRef.current = false;
return;
}
setIsOpen((prev) => !prev);
}, [setIsOpen]);
if (isOpen) {
setIsOpen(false);
return;
}
// iOS only opens the soft keyboard from a focus() that runs while
// the originating user-gesture is still active, AND the focused
// element must not be `disabled`. The real composer input renders
// disabled until the chat session is created, so we focus an
// always-mounted stealth input here to claim the keyboard now; the
// auto-focus effect below transfers focus to the real input once
// it is enabled, which keeps the keyboard up.
if (typeof window !== "undefined" && window.innerWidth <= QUICK_CHAT_DESKTOP_BREAKPOINT) {
stealthInputRef.current?.focus({ preventScroll: true });
}
setIsOpen(true);
}, [isOpen, setIsOpen]);
return (
<>
<input
ref={stealthInputRef}
type="text"
className="quick-chat-stealth-input"
aria-hidden="true"
tabIndex={-1}
/>
{showFAB && (
<button
ref={fabRef}
@@ -1551,10 +1619,14 @@ export function QuickChatFAB({
ref={panelRef}
data-testid="quick-chat-panel"
style={{
right: position.x + anchorOffset.right,
bottom: panelY + anchorOffset.bottom,
...(shouldApplyDesktopPanelSize ? { width: panelSize.width, height: panelSize.height } : {}),
...keyboardPanelStyle,
...(shouldApplyDesktopPanelSize
? {
right: position.x + anchorOffset.right,
bottom: panelY + anchorOffset.bottom,
width: panelSize.width,
height: panelSize.height,
}
: {}),
}}
>
{shouldApplyDesktopPanelSize && (
@@ -1635,11 +1707,31 @@ export function QuickChatFAB({
<div className="quick-chat-panel-header">
<div className="quick-chat-panel-title-wrap">
<h3>Quick Chat</h3>
{chatMode === "model" && selectedModelTag && (
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
{selectedModelTag}
</span>
)}
{chatMode === "model" && selectedModelTag && (() => {
const provider =
selectedModelInfo?.provider ?? parsedModelSelection?.modelProvider ?? "";
// On mobile the header pill is squeezed by mode toggle + new-chat
// + close buttons, so swap a long model name for the provider
// icon to keep the title row tidy.
const tagTooLong = viewportMode === "mobile" && selectedModelTag.length > 12;
if (tagTooLong && provider) {
return (
<span
className="quick-chat-model-tag quick-chat-model-tag--icon"
data-testid="quick-chat-model-tag"
title={selectedModelTag}
aria-label={selectedModelTag}
>
<ProviderIcon provider={provider} size="sm" />
</span>
);
}
return (
<span className="quick-chat-model-tag" data-testid="quick-chat-model-tag" title={selectedModelTag}>
{selectedModelTag}
</span>
);
})()}
</div>
<div className="quick-chat-panel-header-actions">
{agents.length > 0 && (

View File

@@ -305,6 +305,10 @@ interface SettingsModalProps {
onThemeModeChange?: (mode: ThemeMode) => void;
/** Called when color theme changes */
onColorThemeChange?: (theme: ColorTheme) => void;
/** Current dashboard font scale percentage */
dashboardFontScalePct?: number;
/** Called when dashboard font scale changes */
onDashboardFontScaleChange?: (scalePct: number) => void;
/** Optional callback when user wants to reopen the onboarding guide */
onReopenOnboarding?: () => void;
}
@@ -318,6 +322,8 @@ export function SettingsModal({
colorTheme = "default",
onThemeModeChange,
onColorThemeChange,
dashboardFontScalePct = 100,
onDashboardFontScaleChange,
onReopenOnboarding,
}: SettingsModalProps) {
const { confirm } = useConfirm();
@@ -2539,6 +2545,7 @@ export function SettingsModal({
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}
dashboardFontScalePct={dashboardFontScalePct}
onThemeModeChange={(mode) => {
setForm((f) => ({ ...f, themeMode: mode }));
onThemeModeChange?.(mode);
@@ -2547,6 +2554,10 @@ export function SettingsModal({
setForm((f) => ({ ...f, colorTheme: theme }));
onColorThemeChange?.(theme);
}}
onDashboardFontScaleChange={(scalePct) => {
setForm((f) => ({ ...f, dashboardFontScalePct: scalePct }));
onDashboardFontScaleChange?.(scalePct);
}}
/>
</>
);

View File

@@ -140,6 +140,34 @@ function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
return Math.max(0, endMs - startedMs);
}
function getInReviewCompletionMs(task: Task): number | null {
return task.column === "done" ? getDoneCompletionMs(task) : null;
}
function getMergeElapsedMs(task: Task, nowMs: number): number | null {
const mergeStartedMs = parseTimestampToMs(task.updatedAt);
if (mergeStartedMs == null) {
return null;
}
return Math.max(0, nowMs - mergeStartedMs);
}
function getActiveMergeTotalMs(task: Task, nowMs: number): number | null {
const endToEndMs = getEndToEndDurationMs(task, nowMs);
if (endToEndMs != null) {
return endToEndMs;
}
const mergeElapsedMs = getMergeElapsedMs(task, nowMs);
const instrumentedMs = getInstrumentedDurationMs(task, nowMs);
if (instrumentedMs != null) {
return instrumentedMs + (mergeElapsedMs ?? 0);
}
return mergeElapsedMs;
}
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
@@ -711,7 +739,7 @@ function TaskCardComponent({
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
if (!merging && task.column === "in-progress") {
if (task.column === "in-progress") {
const endToEndMs = getEndToEndDurationMs(task, Date.now());
const elapsedMs = getInProgressElapsedMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
@@ -741,20 +769,23 @@ function TaskCardComponent({
return null;
}
// While a merge is actively running, the per-step instrumented duration
// is frozen (the merge phase isn't tracked as a workflow step). Show
// live elapsed since `updatedAt` — which the merger sets when it flips
// status to "merging" — so stuck merges don't appear stuck at "3m".
// While a merge is actively running, continue showing live end-to-end
// execution time. For legacy tasks without executionStartedAt, fall back
// to instrumented runtime plus live merge-phase elapsed since `updatedAt`.
if (task.status != null && ACTIVE_MERGE_STATUSES.has(task.status)) {
const startedMs = parseTimestampToMs(task.updatedAt);
if (startedMs != null) {
const elapsedMs = Math.max(0, timeIndicatorNowMs - startedMs);
const elapsedLabel = formatElapsedDuration(elapsedMs);
const totalMs = getActiveMergeTotalMs(task, timeIndicatorNowMs);
if (totalMs != null) {
const elapsedLabel = formatElapsedDurationDone(totalMs);
if (elapsedLabel) {
const mergeElapsedMs = getMergeElapsedMs(task, timeIndicatorNowMs);
const mergeLabel = mergeElapsedMs == null ? null : formatElapsedDuration(mergeElapsedMs);
const title = mergeLabel
? `Execution time ${elapsedLabel}. Merge phase ${mergeLabel}`
: `Execution time ${elapsedLabel}. Merging`;
return {
label: elapsedLabel,
title: `Merging ${elapsedLabel}`,
ariaLabel: `Merging ${elapsedLabel}`,
title,
ariaLabel: title,
};
}
}
@@ -798,7 +829,7 @@ function TaskCardComponent({
return null;
}
const completionMs = getDoneCompletionMs(task);
const completionMs = getInReviewCompletionMs(task);
if (completionMs == null) {
return {
label: elapsedLabel,

View File

@@ -5,8 +5,10 @@ import type { ThemeMode, ColorTheme } from "@fusion/core";
interface ThemeSelectorProps {
themeMode: ThemeMode;
colorTheme: ColorTheme;
dashboardFontScalePct?: number;
onThemeModeChange: (mode: ThemeMode) => void;
onColorThemeChange: (theme: ColorTheme) => void;
onDashboardFontScaleChange?: (scalePct: number) => void;
}
const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
@@ -15,6 +17,13 @@ const THEME_MODES: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
{ value: "system", label: "System", icon: Monitor },
];
const FONT_SCALE_OPTIONS = [
{ value: 90, label: "Small" },
{ value: 100, label: "Default" },
{ value: 110, label: "Large" },
{ value: 120, label: "Largest" },
] as const;
const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] = [
{ value: "default", label: "Default", className: "theme-swatch-default" },
{ value: "ocean", label: "Ocean", className: "theme-swatch-ocean" },
@@ -78,13 +87,16 @@ const COLOR_THEMES: { value: ColorTheme; label: string; className: string }[] =
export function ThemeSelector({
themeMode,
colorTheme,
dashboardFontScalePct = 100,
onThemeModeChange,
onColorThemeChange,
onDashboardFontScaleChange = () => {},
}: ThemeSelectorProps) {
const handleReset = useCallback(() => {
onThemeModeChange("dark");
onColorThemeChange("default");
}, [onThemeModeChange, onColorThemeChange]);
onDashboardFontScaleChange(100);
}, [onThemeModeChange, onColorThemeChange, onDashboardFontScaleChange]);
return (
<div className="theme-selector">
@@ -126,6 +138,20 @@ export function ThemeSelector({
</div>
</div>
<div className="theme-section-title">Font Size</div>
<div className="theme-font-size-toggle" role="radiogroup" aria-label="Dashboard font size">
{FONT_SCALE_OPTIONS.map(({ value, label }) => (
<button
key={value}
className={`theme-font-size-btn${dashboardFontScalePct === value ? " active" : ""}`}
onClick={() => onDashboardFontScaleChange(value)}
aria-pressed={dashboardFontScalePct === value}
>
<span>{label}</span>
</button>
))}
</div>
{/* Color Theme Grid */}
<div className="theme-section-title">Color Theme</div>
<div className="theme-grid" role="radiogroup" aria-label="Color theme">

View File

@@ -2053,7 +2053,7 @@ describe("QuickChatFAB", () => {
});
});
it("sets keyboard overlap CSS variable when mobile viewport shrinks", async () => {
it("mirrors --vv-height onto the panel when the mobile viewport shrinks", async () => {
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
@@ -2062,7 +2062,9 @@ describe("QuickChatFAB", () => {
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
const panel = await screen.findByTestId("quick-chat-panel");
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
// Panel layout effect runs on first render even with no keyboard, so
// --vv-height starts mirroring the current visual viewport.
expect(panel.style.getPropertyValue("--vv-height")).toBe("844px");
Object.defineProperty(window, "innerHeight", {
value: 560,
@@ -2080,12 +2082,11 @@ describe("QuickChatFAB", () => {
});
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("284px");
expect(panel.style.getPropertyValue("--vv-height")).toBe("560px");
});
});
it("applies --vv-height when keyboard opens with zero overlap (iOS last-resort signal)", async () => {
it("applies --vv-height for the iOS last-resort signal (offsetTop nonzero, height shrunk)", async () => {
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 800,
@@ -2094,17 +2095,8 @@ describe("QuickChatFAB", () => {
render(<QuickChatFAB addToast={addToast} open={true} onOpenChange={vi.fn()} />);
const panel = await screen.findByTestId("quick-chat-panel");
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
expect(panel.style.getPropertyValue("--vv-height")).toBe("800px");
// Focus the input so the hook treats the active element as
// keyboard-focusable — this is what unlocks the iOS last-resort branch
// where viewport shrinks but offsetTop+height closes the gap.
const input = await screen.findByTestId("quick-chat-input") as HTMLTextAreaElement;
input.focus();
// chromeOverlap and gap both collapse to 0 (offsetTop + height ==
// innerHeight == baseline), but baselineHeight - vv.height = 16 trips
// the "viewport shrank" branch with overlap=0, keyboardOpen=true.
Object.defineProperty(mockVV, "height", { value: 784, writable: true, configurable: true });
Object.defineProperty(mockVV, "offsetTop", { value: 16, writable: true, configurable: true });
@@ -2112,15 +2104,12 @@ describe("QuickChatFAB", () => {
for (const cb of listeners.resize) cb();
});
// Panel style is gated on keyboardOpen, not keyboardOverlap > 0.
// Regression guard for the gating change in QuickChatFAB.tsx:805.
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("0px");
expect(panel.style.getPropertyValue("--vv-height")).toBe("784px");
});
});
it("clears keyboard overlap CSS variable when keyboard closes", async () => {
it("updates --vv-height when the keyboard collapses back to baseline", async () => {
const { listeners, mockVV } = mockMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
@@ -2131,7 +2120,7 @@ describe("QuickChatFAB", () => {
const panel = await screen.findByTestId("quick-chat-panel");
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("200px");
expect(panel.style.getPropertyValue("--vv-height")).toBe("600px");
});
Object.defineProperty(mockVV, "height", {
@@ -2145,8 +2134,7 @@ describe("QuickChatFAB", () => {
});
await waitFor(() => {
expect(panel.style.getPropertyValue("--keyboard-overlap")).toBe("");
expect(panel.style.getPropertyValue("--vv-height")).toBe("");
expect(panel.style.getPropertyValue("--vv-height")).toBe("800px");
});
});

View File

@@ -410,6 +410,38 @@ describe("SettingsModal", () => {
});
});
describe("Appearance", () => {
it("renders dashboard font size options with saved value", async () => {
const onDashboardFontScaleChange = vi.fn();
renderModal({ dashboardFontScalePct: 110, onDashboardFontScaleChange });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
const largeButton = screen.getByRole("button", { name: "Large" });
expect(largeButton).toHaveAttribute("aria-pressed", "true");
await userEvent.click(screen.getByRole("button", { name: "Small" }));
expect(onDashboardFontScaleChange).toHaveBeenCalledWith(90);
});
it("saves dashboard font scale to global settings", async () => {
renderModal({ dashboardFontScalePct: 100 });
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: /Appearance/ }));
await userEvent.click(screen.getByRole("button", { name: "Largest" }));
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateGlobalSettings).toHaveBeenCalled();
});
const payload = mockUpdateGlobalSettings.mock.calls[0][0];
expect(payload).toEqual(expect.objectContaining({ dashboardFontScalePct: 120 }));
});
});
describe("Project Models", () => {
it("renders a project-scoped default model lane", async () => {
mockFetchSettings.mockResolvedValue({

View File

@@ -736,6 +736,37 @@ describe("TaskCard", () => {
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("12m");
expect(timer?.getAttribute("title")).toContain("Execution time 12m");
expect(timer?.getAttribute("title")).not.toContain("Completed");
});
it("keeps the in-review timer live from executionStartedAt when present", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-25T12:30:00.000Z"));
const { container } = render(
<TaskCard
task={makeTask({
column: "in-review",
executionStartedAt: "2026-04-25T12:00:00.000Z",
columnMovedAt: "2026-04-25T12:12:00.000Z",
updatedAt: "2026-04-25T12:30:00.000Z",
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("30m");
expect(timer?.getAttribute("title")).toBe("Execution time 30m");
act(() => {
vi.advanceTimersByTime(5 * 60_000);
});
expect(container.querySelector(".card-time-indicator")?.textContent).toContain("35m");
expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m");
});
it("shows live merge elapsed in timer chip while task.status is merging", () => {
@@ -748,7 +779,8 @@ describe("TaskCard", () => {
task={makeTask({
column: "in-review",
status: "merging",
updatedAt: "2026-04-25T13:00:00.000Z",
executionStartedAt: "2026-04-25T13:00:00.000Z",
updatedAt: "2026-04-25T13:44:30.000Z",
workflowStepResults: [
{
workflowStepId: "step-1",
@@ -768,7 +800,7 @@ describe("TaskCard", () => {
const timer = container.querySelector(".card-time-indicator");
expect(timer).not.toBeNull();
expect(timer?.textContent).toContain("45m");
expect(timer?.getAttribute("title")).toBe("Merging 45m");
expect(timer?.getAttribute("title")).toBe("Execution time 45m. Merge phase <1m");
} finally {
vi.useRealTimers();
}

View File

@@ -18,6 +18,7 @@ vi.mock("../../api", () => ({
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
const mockFetchGlobalSettings = vi.mocked(fetchGlobalSettings);
const mockUpdateGlobalSettings = vi.mocked(updateGlobalSettings);
@@ -77,6 +78,7 @@ describe("useTheme", () => {
// Clear document attributes
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-color-theme");
document.documentElement.style.fontSize = "";
// Clear any theme-data stylesheet links from previous tests
document.querySelectorAll('link[id="theme-data"]').forEach((link) => link.remove());
@@ -133,6 +135,18 @@ describe("useTheme", () => {
expect(localStorageMock[COLOR_THEME_STORAGE_KEY]).toBe("ocean");
});
it("hydrates dashboard font scale from backend on mount", async () => {
mockFetchGlobalSettings.mockResolvedValue({ dashboardFontScalePct: 110 });
const { result } = renderHook(() => useTheme());
await waitFor(() => {
expect(result.current.dashboardFontScalePct).toBe(110);
});
expect(localStorageMock[FONT_SCALE_STORAGE_KEY]).toBe("110");
expect(document.documentElement.style.fontSize).toBe("110%");
});
it("prefers backend over localStorage on hydration", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockResolvedValue({ themeMode: "dark" });
@@ -279,6 +293,19 @@ describe("useTheme", () => {
resolveUpdate!({} as Settings);
});
it("write-through persists dashboard font scale updates", () => {
const { result } = renderHook(() => useTheme());
act(() => {
result.current.setDashboardFontScalePct(120);
});
expect(result.current.dashboardFontScalePct).toBe(120);
expect(localStorageMock[FONT_SCALE_STORAGE_KEY]).toBe("120");
expect(mockUpdateGlobalSettings).toHaveBeenCalledWith({ dashboardFontScalePct: 120 });
expect(document.documentElement.style.fontSize).toBe("120%");
});
it("backend hydration failure falls back to localStorage", async () => {
localStorageMock[THEME_MODE_STORAGE_KEY] = "light";
mockFetchGlobalSettings.mockRejectedValue(new Error("network unavailable"));
@@ -499,6 +526,15 @@ describe("useTheme", () => {
expect(result.current.colorTheme).toBe("default");
});
it("clamps invalid dashboard font scale values from localStorage", () => {
localStorageMock[FONT_SCALE_STORAGE_KEY] = "400";
const { result } = renderHook(() => useTheme());
expect(result.current.dashboardFontScalePct).toBe(125);
expect(document.documentElement.style.fontSize).toBe("125%");
});
it("falls back to defaults when localStorage throws", () => {
vi.stubGlobal("localStorage", {
getItem: () => {
@@ -1136,6 +1172,7 @@ describe("getThemeInitScript", () => {
expect(script).toContain("localStorage");
expect(script).toContain("data-theme");
expect(script).toContain("data-color-theme");
expect(script).toContain("style.fontSize");
});
it("includes the correct localStorage keys", () => {
@@ -1143,6 +1180,7 @@ describe("getThemeInitScript", () => {
expect(script).toContain(THEME_MODE_STORAGE_KEY);
expect(script).toContain(COLOR_THEME_STORAGE_KEY);
expect(script).toContain(FONT_SCALE_STORAGE_KEY);
});
it("includes every supported theme in the validated theme list", () => {

View File

@@ -43,13 +43,21 @@ function isKeyboardFocusableElement(el: Element | null): boolean {
return el instanceof HTMLElement && el.isContentEditable;
}
function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: number | null } {
interface KeyboardMetrics {
overlap: number;
open: boolean;
vvHeight: number | null;
vvOffsetTop: number;
}
function getKeyboardMetrics(): KeyboardMetrics {
if (typeof window === "undefined" || !window.visualViewport) {
return { overlap: 0, open: false, vvHeight: null };
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
}
const vv = window.visualViewport;
const focused = isKeyboardFocusableElement(document.activeElement);
const offsetTop = vv.offsetTop;
// Only refresh baseline while keyboard is likely closed.
if (!focused) {
@@ -59,7 +67,7 @@ function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: numbe
// Android/Chrome style overlap.
const chromeOverlap = Math.max(0, window.innerHeight - vv.offsetTop - vv.height);
if (chromeOverlap > 0) {
return { overlap: chromeOverlap, open: true, vvHeight: vv.height };
return { overlap: chromeOverlap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
// iOS fallback (window.innerHeight shrinks with keyboard).
@@ -67,20 +75,20 @@ function getKeyboardMetrics(): { overlap: number; open: boolean; vvHeight: numbe
const gap = Math.max(0, baselineHeight - vv.offsetTop - vv.height);
if (gap >= IOS_FALLBACK_MIN_GAP_PX) {
return { overlap: gap, open: true, vvHeight: vv.height };
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
if (gap >= IOS_FALLBACK_MIN_FOCUSED_GAP_PX && focused) {
return { overlap: gap, open: true, vvHeight: vv.height };
return { overlap: gap, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
// Last-resort signal: focused input + meaningful viewport shrink.
const viewportShrink = Math.max(0, baselineHeight - vv.height);
if (focused && viewportShrink >= IOS_VIEWPORT_SHRINK_MIN_PX) {
return { overlap: 0, open: true, vvHeight: vv.height };
return { overlap: 0, open: true, vvHeight: vv.height, vvOffsetTop: offsetTop };
}
return { overlap: 0, open: false, vvHeight: null };
return { overlap: 0, open: false, vvHeight: null, vvOffsetTop: 0 };
}
/** Reset cached viewport baseline. Exported for tests only. */
@@ -94,15 +102,17 @@ interface UseMobileKeyboardOptions {
export function useMobileKeyboard(
{ enabled = true }: UseMobileKeyboardOptions = {},
): { keyboardOverlap: number; viewportHeight: number | null; keyboardOpen: boolean } {
): { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean } {
const [keyboardOverlap, setKeyboardOverlap] = useState(0);
const [viewportHeight, setViewportHeight] = useState<number | null>(null);
const [viewportOffsetTop, setViewportOffsetTop] = useState(0);
const [keyboardOpen, setKeyboardOpen] = useState(false);
useEffect(() => {
if (!enabled || !isMobileDevice()) {
setKeyboardOverlap(0);
setViewportHeight(null);
setViewportOffsetTop(0);
setKeyboardOpen(false);
return;
}
@@ -111,6 +121,7 @@ export function useMobileKeyboard(
if (!vv) {
setKeyboardOverlap(0);
setViewportHeight(null);
setViewportOffsetTop(0);
setKeyboardOpen(false);
return;
}
@@ -119,6 +130,7 @@ export function useMobileKeyboard(
const metrics = getKeyboardMetrics();
setKeyboardOverlap(metrics.overlap);
setViewportHeight(metrics.vvHeight);
setViewportOffsetTop(metrics.vvOffsetTop);
setKeyboardOpen(metrics.open);
};
@@ -135,9 +147,10 @@ export function useMobileKeyboard(
document.removeEventListener("focusout", update);
setKeyboardOverlap(0);
setViewportHeight(null);
setViewportOffsetTop(0);
setKeyboardOpen(false);
};
}, [enabled]);
return { keyboardOverlap, viewportHeight, keyboardOpen };
return { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen };
}

View File

@@ -4,6 +4,10 @@ import { fetchGlobalSettings, updateGlobalSettings } from "../api";
const THEME_MODE_STORAGE_KEY = "kb-dashboard-theme-mode";
const COLOR_THEME_STORAGE_KEY = "kb-dashboard-color-theme";
const FONT_SCALE_STORAGE_KEY = "kb-dashboard-font-scale-pct";
const DEFAULT_FONT_SCALE_PCT = 100;
const MIN_FONT_SCALE_PCT = 85;
const MAX_FONT_SCALE_PCT = 125;
const VALID_COLOR_THEMES = [...COLOR_THEMES] satisfies ColorTheme[];
const THEME_DATA_ID = "theme-data";
const THEME_DATA_FILENAME = "theme-data.css";
@@ -51,8 +55,10 @@ const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
interface UseThemeReturn {
themeMode: ThemeMode;
colorTheme: ColorTheme;
dashboardFontScalePct: number;
setThemeMode: (mode: ThemeMode) => void;
setColorTheme: (theme: ColorTheme) => void;
setDashboardFontScalePct: (scalePct: number) => void;
isSystemDark: boolean;
}
@@ -104,6 +110,32 @@ function writeCachedColorTheme(theme: ColorTheme): void {
}
}
function normalizeFontScalePct(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_FONT_SCALE_PCT;
}
return Math.min(MAX_FONT_SCALE_PCT, Math.max(MIN_FONT_SCALE_PCT, Math.round(value)));
}
function readCachedDashboardFontScalePct(): number {
if (!isBrowser) return DEFAULT_FONT_SCALE_PCT;
try {
const saved = Number(localStorage.getItem(FONT_SCALE_STORAGE_KEY));
return normalizeFontScalePct(saved);
} catch {
return DEFAULT_FONT_SCALE_PCT;
}
}
function writeCachedDashboardFontScalePct(scalePct: number): void {
if (!isBrowser) return;
try {
localStorage.setItem(FONT_SCALE_STORAGE_KEY, String(normalizeFontScalePct(scalePct)));
} catch {
// localStorage not available, skip cache write
}
}
/**
* Get the effective theme mode (resolves "system" to actual dark/light value)
*/
@@ -118,12 +150,18 @@ function getEffectiveThemeMode(mode: ThemeMode, systemIsDark: boolean): "dark" |
* Apply theme attributes to document.documentElement
* Call this immediately to prevent flash of wrong theme
*/
function applyThemeAttributes(themeMode: ThemeMode, colorTheme: ColorTheme, systemIsDark: boolean): void {
function applyThemeAttributes(
themeMode: ThemeMode,
colorTheme: ColorTheme,
dashboardFontScalePct: number,
systemIsDark: boolean,
): void {
if (!isBrowser) return;
const effectiveMode = getEffectiveThemeMode(themeMode, systemIsDark);
document.documentElement.setAttribute("data-theme", effectiveMode);
document.documentElement.setAttribute("data-color-theme", colorTheme);
document.documentElement.style.fontSize = `${normalizeFontScalePct(dashboardFontScalePct)}%`;
}
/**
@@ -193,6 +231,7 @@ export function useTheme(): UseThemeReturn {
// Initialize from localStorage cache or defaults to avoid flash before hydration.
const [themeMode, setThemeModeState] = useState<ThemeMode>(() => readCachedThemeMode());
const [colorTheme, setColorThemeState] = useState<ColorTheme>(() => readCachedColorTheme());
const [dashboardFontScalePct, setDashboardFontScalePctState] = useState<number>(() => readCachedDashboardFontScalePct());
const [isHydrating, setIsHydrating] = useState(true);
// Track system color scheme preference
@@ -203,8 +242,10 @@ export function useTheme(): UseThemeReturn {
const themeModeRef = useRef(themeMode);
const colorThemeRef = useRef(colorTheme);
const dashboardFontScalePctRef = useRef(dashboardFontScalePct);
const userSetThemeModeRef = useRef(false);
const userSetColorThemeRef = useRef(false);
const userSetDashboardFontScalePctRef = useRef(false);
useEffect(() => {
themeModeRef.current = themeMode;
@@ -214,6 +255,10 @@ export function useTheme(): UseThemeReturn {
colorThemeRef.current = colorTheme;
}, [colorTheme]);
useEffect(() => {
dashboardFontScalePctRef.current = dashboardFontScalePct;
}, [dashboardFontScalePct]);
// Hydrate canonical theme values from backend global settings.
useEffect(() => {
if (!isBrowser || !isHydrating) return;
@@ -249,6 +294,17 @@ export function useTheme(): UseThemeReturn {
writeCachedColorTheme(globalSettings.colorTheme);
}
}
if (!userSetDashboardFontScalePctRef.current) {
const hydratedScalePct = normalizeFontScalePct(globalSettings.dashboardFontScalePct);
if (dashboardFontScalePctRef.current !== hydratedScalePct) {
dashboardFontScalePctRef.current = hydratedScalePct;
setDashboardFontScalePctState(hydratedScalePct);
}
if (readCachedDashboardFontScalePct() !== hydratedScalePct) {
writeCachedDashboardFontScalePct(hydratedScalePct);
}
}
})
.catch((error) => {
console.warn("[useTheme] Failed to hydrate theme from global settings", error);
@@ -279,8 +335,8 @@ export function useTheme(): UseThemeReturn {
// Apply theme immediately on mount and when theme changes
useIsomorphicLayoutEffect(() => {
applyThemeAttributes(themeMode, colorTheme, isSystemDark);
}, [themeMode, colorTheme, isSystemDark]);
applyThemeAttributes(themeMode, colorTheme, dashboardFontScalePct, isSystemDark);
}, [themeMode, colorTheme, dashboardFontScalePct, isSystemDark]);
// Ensure theme-data.css is loaded/unloaded based on colorTheme.
// This handles both initial hydration from backend and runtime theme changes.
@@ -325,11 +381,25 @@ export function useTheme(): UseThemeReturn {
});
}, []);
const setDashboardFontScalePct = useCallback((scalePct: number) => {
const normalizedScalePct = normalizeFontScalePct(scalePct);
userSetDashboardFontScalePctRef.current = true;
dashboardFontScalePctRef.current = normalizedScalePct;
setDashboardFontScalePctState(normalizedScalePct);
writeCachedDashboardFontScalePct(normalizedScalePct);
void updateGlobalSettings({ dashboardFontScalePct: normalizedScalePct }).catch((error) => {
console.warn("[useTheme] Failed to persist dashboardFontScalePct to global settings", error);
});
}, []);
return {
themeMode,
colorTheme,
dashboardFontScalePct,
setThemeMode,
setColorTheme,
setDashboardFontScalePct,
isSystemDark,
};
}
@@ -350,13 +420,20 @@ export function getThemeInitScript(): string {
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}
var fontScale = Number(localStorage.getItem('${FONT_SCALE_STORAGE_KEY}') || '${DEFAULT_FONT_SCALE_PCT}');
if (!Number.isFinite(fontScale)) {
fontScale = ${DEFAULT_FONT_SCALE_PCT};
}
fontScale = Math.min(${MAX_FONT_SCALE_PCT}, Math.max(${MIN_FONT_SCALE_PCT}, Math.round(fontScale)));
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', effectiveMode);
document.documentElement.setAttribute('data-color-theme', colorTheme);
document.documentElement.style.fontSize = fontScale + '%';
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-color-theme', 'default');
document.documentElement.style.fontSize = '${DEFAULT_FONT_SCALE_PCT}%';
}
})();
`;

View File

@@ -21,10 +21,16 @@
if (!validThemes.includes(colorTheme)) {
colorTheme = 'default';
}
var fontScale = Number(localStorage.getItem('kb-dashboard-font-scale-pct') || '100');
if (!Number.isFinite(fontScale)) {
fontScale = 100;
}
fontScale = Math.min(125, Math.max(85, Math.round(fontScale)));
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var effectiveMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', effectiveMode);
document.documentElement.setAttribute('data-color-theme', colorTheme);
document.documentElement.style.fontSize = fontScale + '%';
// Load theme-data.css for non-default themes to prevent flash
// Use path-safe resolution that works in both HTTP and file:// contexts
if (colorTheme !== 'default') {
@@ -75,6 +81,7 @@
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.setAttribute('data-color-theme', 'default');
document.documentElement.style.fontSize = '100%';
}
})();
</script>