feat(dashboard): full issue/PR preview, floating dock pop-out, header dedups, gm one-row, single-line quick entry

- GitHub import preview shows the full issue/PR body (markdown) + metadata (list already returned full bodies; removed client truncation).
- Task-detail main view: constrain width (no right cutoff); move 'Back to board' into the gray header far right.
- Dock pop-out: smoother touch drag (touch-action:none + captured-element listeners); popping out closes the dock but keeps the floating modal; modal survives dock dismiss.
- Remove duplicate inner headers in Git Manager / Activity Log / Dev Server / Secrets (dock + pop-out chrome already titles them); keep the title on mobile narrow; relocate gm Refresh into the section tab strip.
- Git Manager tabs: one scrollable row of compact icon-only tabs (max visible, scroll if needed).
- List quick entry is single-line (not tall) via singleLine prop; Board unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 04:37:28 -07:00
parent cc2753c8a2
commit e6aebdc316
23 changed files with 716 additions and 139 deletions

View File

@@ -14,7 +14,6 @@ import { Board } from "./components/Board";
import { TaskCard } from "./components/TaskCard";
import { ListView } from "./components/ListView";
import { TaskDetailContent } from "./components/TaskDetailModal";
import { ArrowLeft } from "lucide-react";
import { ProjectOverview } from "./components/ProjectOverview";
import { MissionManager } from "./components/MissionManager";
import { MailboxView } from "./components/MailboxView";
@@ -2043,22 +2042,17 @@ function AppInner() {
return (
<PageErrorBoundary>
<div className="task-detail-main-panel">
<div className="task-detail-main-panel-back-row">
<button
type="button"
className="task-detail-main-panel-back-btn"
onClick={closeTaskDetailMainPanel}
>
<ArrowLeft size={16} aria-hidden="true" />
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
</button>
</div>
<div className="task-detail-main-panel-body">
<TaskDetailContent
task={liveDetailTask}
projectId={currentProject?.id}
tasks={tasks}
embedded
/*
FNXC:TaskDetail 2026-06-22-18:40:
Board-card detail (full main panel) renders its "Back to board" affordance inside TaskDetailContent's gray header (far right, across from the task id) instead of a separate back-row above the content. The prop only renders the header back button when both embedded and onBackToBoard are present, so ListView split-pane and modal usages stay unaffected.
*/
onBackToBoard={closeTaskDetailMainPanel}
onOpenDetail={(value) => setMainPanelDetailTask(value)}
onMoveTask={moveTask}
onDeleteTask={deleteTask}

View File

@@ -2339,12 +2339,19 @@ export function clearApiKey(provider: string): Promise<{ success: boolean }> {
// --- GitHub Import API ---
/** GitHub issue returned by the fetch endpoint */
/*
FNXC:GitHubImport 2026-06-22-18:30:
The Import Tasks preview pane renders the FULL issue (full body + metadata), so the list response carries the complete body plus author/state.
The GitHub issue-list endpoint already returns the full (untruncated) `body`; no per-item detail fetch is needed. `author`/`state` are surfaced for the preview metadata row.
*/
export interface GitHubIssue {
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
state?: "open" | "closed";
author?: string | null;
}
/** Fetch open GitHub issues from a repository */
@@ -2394,7 +2401,10 @@ export function apiBatchImportGitHubIssues(
// --- GitHub Pull Request Import API ---
/** GitHub pull request returned by the fetch endpoint */
/*
FNXC:GitHubImport 2026-06-22-18:30:
The PR-list endpoint already returns the full (untruncated) `body`; the import preview renders it in full with no per-item detail fetch. `state`/`author` surface PR metadata in the preview.
*/
export interface GitHubPull {
number: number;
title: string;
@@ -2402,6 +2412,8 @@ export interface GitHubPull {
html_url: string;
headBranch: string;
baseBranch: string;
state?: "open" | "closed" | "merged";
author?: string | null;
}
/** Fetch open GitHub pull requests from a repository */

View File

@@ -33,6 +33,24 @@ The embedded root is a plain flow box that fills the dock; the inner panel sheds
container-name: activity-log-embedded;
}
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
In the right dock the tab strip already labels the view, and in the pop-out the RightDockExpandModal supplies its own header — so the embedded variant's inner "Activity Log" header row (.activity-log-header) is redundant chrome there. Hide it by default in the embedded variant. The header stays in the DOM (not unmounted) so query-by-text/test hooks still resolve; only display is suppressed. The body's flex column fills the freed space since the header was flex-shrink:0.
*/
.activity-log-modal--embedded .activity-log-header {
display: none;
}
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
On real mobile-narrow the view goes full-screen with no dock tab strip or pop-out header, so it must own its own title again. The viewport @media (max-width:768px) fires only on a true narrow viewport (never inside the desktop dock/pop-out, where the @container query drives layout instead), so restoring the header here brings the title back exactly when the chrome is gone.
*/
@media (max-width: 768px) {
.activity-log-modal--embedded .activity-log-header {
display: flex;
}
}
/*
FNXC:RightDockEmbedded 2026-06-22-00:00:
Mirror the phone-width (@media max-width:768px) activity-log layout-stacking rules for the narrow dock, scoped to the

View File

@@ -20,6 +20,35 @@ Header migrated to the shared ViewHeader (.view-header), which supplies the --sp
FNXC:DevServer 2026-06-22-01:00:
.dev-server-header-title now wraps just the status badge inside ViewHeader's actions slot; the mobile flex-wrap rule keeps it from overflowing on narrow widths.
*/
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
DevServerView is a right-dock tool with no --embedded variant; it renders directly inside the dock body
(.right-dock__body) and inside the pop-out (.right-dock-expand-modal__body). In both, the chrome already labels the
view — the dock tab strip names it, and the pop-out's RightDockExpandModal supplies its own header — so the view's own
shared ViewHeader (.view-header) is redundant title chrome there. Hide it in those two host contexts. The header stays
in the DOM; only display is suppressed. The base view is a flex column, so the panels fill the freed space (the header
slot was auto-height, not a fixed reserve). The standalone full-page and Settings-section renders are NOT inside these
ancestors, so their header stays visible.
*/
.right-dock__body .dev-server-view > .view-header,
.right-dock-expand-modal__body .dev-server-view > .view-header {
display: none;
}
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
On real mobile-narrow the view goes full-screen with no dock tab strip or pop-out header, so it must own its title
again. The viewport @media (max-width:768px) fires only on a true narrow viewport (never in the desktop dock/pop-out,
where the @container right-dock-body query drives layout instead), so restoring the header here brings the title back
exactly when the surrounding chrome is gone.
*/
@media (max-width: 768px) {
.right-dock__body .dev-server-view > .view-header,
.right-dock-expand-modal__body .dev-server-view > .view-header {
display: flex;
}
}
.dev-server-header-title {
display: flex;
align-items: center;

View File

@@ -677,6 +677,81 @@
word-break: break-word;
}
/*
FNXC:GitHubImport 2026-06-22-18:30:
Full-body preview metadata row (state badge, author, GitHub link) plus the markdown body wrapper.
The markdown variant must NOT pre-wrap/clamp — MailboxMessageContent emits real block elements (p, ul, pre, table), so reset the plain-text white-space and let the body take full height; the preview pane already owns the vertical scroll.
*/
.preview-metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
font-size: 12px;
color: var(--text-muted);
}
.preview-state-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
}
.preview-state-badge--open {
color: var(--success, var(--accent));
border-color: color-mix(in srgb, var(--success, var(--accent)) 40%, transparent);
}
.preview-state-badge--closed {
color: var(--danger, var(--text-muted));
border-color: color-mix(in srgb, var(--danger, var(--text-muted)) 40%, transparent);
}
.preview-state-badge--merged {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.preview-author {
color: var(--text-muted);
}
.preview-url {
color: var(--accent);
text-decoration: none;
}
.preview-url:hover {
text-decoration: underline;
}
.preview-labels {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.preview-body--markdown {
white-space: normal;
word-break: break-word;
color: var(--text);
}
.preview-body--markdown :where(p, ul, ol, pre, table, blockquote, h1, h2, h3, h4) {
margin: 0 0 var(--space-sm);
}
.preview-body--markdown :where(p, ul, ol, pre, table, blockquote, h1, h2, h3, h4):last-child {
margin-bottom: 0;
}
/* Back button - hidden on desktop by default */
.github-import-back-button {
display: none;

View File

@@ -15,6 +15,7 @@ import {
} from "../api";
import { Loader2, RefreshCw, ArrowLeft, GitPullRequest, CircleDot } from "lucide-react";
import { GithubIcon } from "./GithubIcon";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -48,15 +49,12 @@ function clampListPaneWidth(width: number) {
return Math.max(GITHUB_IMPORT_LIST_PANE_MIN_WIDTH, Math.min(GITHUB_IMPORT_LIST_PANE_MAX_WIDTH, width));
}
function formatPreviewBody(body: string | null | undefined, isMobile: boolean) {
if (!body) {
return null;
}
if (isMobile) {
return body;
}
return body.slice(0, 200) + (body.length > 200 ? "…" : "");
}
/*
FNXC:GitHubImport 2026-06-22-18:30:
The Import-from-GitHub preview pane must show the FULL selected issue/PR, not a truncated snapshot.
The list endpoint already returns the complete (untruncated) body, so no per-item detail fetch is needed — the prior 200-char desktop slice in formatPreviewBody was the only thing truncating the preview, and it has been removed.
The full body renders as GitHub-flavored markdown via the shared MailboxMessageContent component; the preview pane is already scrollable (prior fix), so the body takes full height with no line clamping.
*/
export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId, presentation = "modal" }: GitHubImportModalProps) {
const { isEmbedded, scrollLockEnabled, resizePersistEnabled, escapeEnabled } = useEmbeddedPresentation(presentation);
@@ -832,13 +830,43 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
<div className="github-import-pane-content">
{/* Issue preview */}
{/*
FNXC:GitHubImport 2026-06-22-18:30:
Full-issue preview: complete title, full body rendered as markdown, and key metadata (number, state, author, labels, URL). No body truncation/clamping.
*/}
{activeTab === "issues" && selectedIssue ? (
<div className="issue-preview" data-testid="github-import-preview-card">
<div className="preview-meta">{t("git.previewIssueMeta", "Issue #{{number}}", { number: selectedIssue.number })}</div>
<div className="preview-title">{selectedIssue.title}</div>
<div className="preview-body">
{formatPreviewBody(selectedIssue.body, isMobile) || t("git.noDescription", "(no description)")}
<div className="preview-metadata">
{selectedIssue.state && (
<span className={`preview-state-badge preview-state-badge--${selectedIssue.state}`}>{selectedIssue.state}</span>
)}
{selectedIssue.author && (
<span className="preview-author">{t("git.previewAuthor", "by {{author}}", { author: selectedIssue.author })}</span>
)}
<a className="preview-url" href={selectedIssue.html_url} target="_blank" rel="noopener noreferrer">
{t("git.viewOnGitHub", "View on GitHub")}
</a>
</div>
{selectedIssue.labels.length > 0 && (
<span className="preview-labels">
{selectedIssue.labels.map((l) => (
<span key={l.name} className="label-chip">{l.name}</span>
))}
</span>
)}
{selectedIssue.body ? (
<MailboxMessageContent
className="preview-body preview-body--markdown"
content={selectedIssue.body}
testId="github-import-preview-body"
/>
) : (
<div className="preview-body" data-testid="github-import-preview-body">
{t("git.noDescription", "(no description)")}
</div>
)}
</div>
) : activeTab === "issues" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">
@@ -850,16 +878,39 @@ export function GitHubImportModal({ isOpen, onClose, onImport, tasks, projectId,
) : null}
{/* Pull request preview */}
{/*
FNXC:GitHubImport 2026-06-22-18:30:
Full-PR preview: complete title, full body as markdown, and key metadata (number, state, author, base/head branches, URL). No body truncation/clamping.
*/}
{activeTab === "pulls" && selectedPull ? (
<div className="issue-preview" data-testid="github-import-preview-card">
<div className="preview-meta">{t("git.previewPullMeta", "Pull Request #{{number}}", { number: selectedPull.number })}</div>
<div className="preview-title">{selectedPull.title}</div>
<div className="preview-metadata">
{selectedPull.state && (
<span className={`preview-state-badge preview-state-badge--${selectedPull.state}`}>{selectedPull.state}</span>
)}
{selectedPull.author && (
<span className="preview-author">{t("git.previewAuthor", "by {{author}}", { author: selectedPull.author })}</span>
)}
<a className="preview-url" href={selectedPull.html_url} target="_blank" rel="noopener noreferrer">
{t("git.viewOnGitHub", "View on GitHub")}
</a>
</div>
<div className="preview-branch">
<strong>{t("git.branchLabel", "Branch:")}</strong> {selectedPull.headBranch} → {selectedPull.baseBranch}
</div>
<div className="preview-body">
{formatPreviewBody(selectedPull.body, isMobile) || t("git.noDescription", "(no description)")}
</div>
{selectedPull.body ? (
<MailboxMessageContent
className="preview-body preview-body--markdown"
content={selectedPull.body}
testId="github-import-preview-body"
/>
) : (
<div className="preview-body" data-testid="github-import-preview-body">
{t("git.noDescription", "(no description)")}
</div>
)}
</div>
) : activeTab === "pulls" ? (
<div className="github-import-state github-import-state--idle" data-testid="github-import-preview-empty">

View File

@@ -959,6 +959,21 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
</button>
);
})}
{/*
FNXC:GitManager 2026-06-22-19:00:
Refresh relocated from the (now-removed) internal gray .modal-header into the section nav strip so it is reachable on every section ("each page") in BOTH the right-dock embedded view (wrapping tab strip) and the popped-out modal. The dock tab strip and RightDockExpandModal already supply a header, so the internal title+refresh row was a duplicate header and is removed. Same fetchSectionData + loading spinner state as before.
*/}
<button
type="button"
className="gm-nav-refresh"
onClick={fetchSectionData}
disabled={loading}
title={t("git.refresh", "Refresh")}
aria-label={t("git.refresh", "Refresh")}
>
<RefreshCw size={16} className={loading ? "spin" : ""} />
<span className="gm-nav-label">{t("git.refresh", "Refresh")}</span>
</button>
</nav>
{/* Content Area */}
@@ -1121,23 +1136,6 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
return (
<div className="git-manager-embedded right-dock-embedded-view">
<div className="gm-modal gm-modal--embedded" ref={modalRef} style={keyboardStyle}>
<div className="modal-header">
<h3>
<FolderGit2 size={18} style={{ marginRight: 8, verticalAlign: "middle" }} />
{t("git.modalTitle", "Git Manager")}
</h3>
<div className="gm-header-actions">
<button
className="btn btn-sm"
onClick={fetchSectionData}
disabled={loading}
title={t("git.refresh", "Refresh")}
>
<RefreshCw size={14} className={loading ? "spin" : ""} />
</button>
</div>
</div>
<div className="gm-layout">
{gitBody}
</div>
@@ -1155,14 +1153,6 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj
{t("git.modalTitle", "Git Manager")}
</h3>
<div className="gm-header-actions">
<button
className="btn btn-sm"
onClick={fetchSectionData}
disabled={loading}
title={t("git.refresh", "Refresh")}
>
<RefreshCw size={14} className={loading ? "spin" : ""} />
</button>
<button className="modal-close" onClick={handleClose} aria-label={t("git.close", "Close")}>
<X size={18} />
</button>

View File

@@ -2032,6 +2032,7 @@ export function ListView({
projectId={projectId}
autoExpand={false}
defaultExpanded={false}
singleLine /* FNXC:QuickEntry 2026-06-22-19:25: List view uses the compact single-line quick-add so the box stays one line tall. */
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}

View File

@@ -32,6 +32,26 @@
min-height: 80px;
}
/*
FNXC:QuickEntry 2026-06-22-19:25:
List view renders quick-add as a COMPACT single-line input so the box isn't tall.
Clamp the textarea to exactly one line (min-height == max-height == one line), forbid auto-grow/manual resize, and scroll overflow instead of growing.
Tighten container vertical padding so the overall box is just the one-line input height.
Board/columns omit `.quick-entry--single-line`, keeping the tall 80px + auto-grow behavior.
*/
.quick-entry--single-line {
padding-top: var(--space-xs);
padding-bottom: var(--space-xs);
}
.quick-entry-box.quick-entry--single-line .quick-entry-input,
.quick-entry-box.quick-entry--single-line .quick-entry-input--expanded {
min-height: 36px;
max-height: 36px;
overflow-y: auto;
resize: none;
}
@media (max-width: 768px) {
.quick-entry-input--expanded {
min-height: 60px;

View File

@@ -53,6 +53,11 @@ interface QuickEntryBoxProps {
Initial disclosure (expanded controls) state. List view passes false so quick-add starts COLLAPSED; Board/columns keep the default true so quick-add stays OPEN. This is independent of autoExpand (which only governs expand-on-focus).
*/
defaultExpanded?: boolean;
/*
FNXC:QuickEntry 2026-06-22-19:25:
List view renders quick-add as a COMPACT single-line input so the box isn't tall. When true, the textarea stays one line: isExpanded initializes false, focus does NOT auto-expand it, and auto-resize-to-scrollHeight is short-circuited (capped to the one-line min-height). Board/columns omit singleLine, preserving the tall 80px + auto-grow behavior. singleLine governs only textarea height, not the disclosure/controls panel (which List already collapses via defaultExpanded={false}).
*/
singleLine?: boolean;
/**
* Favorited provider IDs from shared app-level state.
* When provided (alongside availableModels), the component uses these
@@ -96,7 +101,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
};
}
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, defaultExpanded = true, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
const { t } = useTranslation("app");
const [description, setDescription] = useState(() => {
if (typeof window !== "undefined") {
@@ -107,7 +112,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [isSubmitting, setIsSubmitting] = useState(false);
const [postSubmitFocusRequest, setPostSubmitFocusRequest] = useState(0);
// isExpanded controls textarea height styling (auto-resize)
const [isExpanded, setIsExpanded] = useState(true);
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) starts collapsed so the textarea is one line, not the tall 80px variant.
const [isExpanded, setIsExpanded] = useState(!singleLine);
// isDisclosureExpanded controls visibility of the controls panel (Deps, Models, etc.)
// Starts expanded by default — controls visible immediately
const [isDisclosureExpanded, setIsDisclosureExpanded] = useState(defaultExpanded);
@@ -325,11 +331,12 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, []);
// Resize when description changes (not in fullscreen mode since CSS handles it)
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) must stay one line — skip auto-resize-to-scrollHeight so the textarea never grows tall with content; CSS clamps it to the one-line height.
useEffect(() => {
if (isExpanded) {
if (isExpanded && !singleLine) {
autoResize();
}
}, [description, isExpanded, autoResize]);
}, [description, isExpanded, autoResize, singleLine]);
const requestFocusAfterSuccessfulSubmit = useCallback(() => {
setPostSubmitFocusRequest((request) => request + 1);
@@ -686,7 +693,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
if (e.shiftKey) {
// Allow Shift+Enter to insert a newline in any quick-entry state
// Don't prevent default - let the newline be inserted
setIsExpanded(true);
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) stays one line even on Shift+Enter — do not expand the textarea.
if (!singleLine) {
setIsExpanded(true);
}
return;
}
// Enter without Shift submits
@@ -763,6 +773,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
projectId,
setIsDisclosureExpanded,
duplicateMatches,
singleLine,
],
);
@@ -776,10 +787,11 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const handleFocus = useCallback(() => {
// Auto-expand on focus when autoExpand prop is true (default)
if (autoExpand) {
// FNXC:QuickEntry 2026-06-22-19:25: never auto-expand the textarea on focus when singleLine (List view) — it must stay one line.
if (autoExpand && !singleLine) {
setIsExpanded(true);
}
}, [autoExpand]);
}, [autoExpand, singleLine]);
const toggleDep = useCallback((id: string) => {
setDependencies((prev) =>
@@ -1478,13 +1490,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
return (
<>
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}`} data-testid="quick-entry-box">
<div className={`quick-entry-box ${isDisclosureExpanded ? "quick-entry-box--expanded" : "quick-entry-box--collapsed"}${singleLine ? " quick-entry--single-line" : ""}`} data-testid="quick-entry-box">
<div className="description-with-refine">
<div className="quick-entry-main-row">
<div className="quick-entry-textarea-wrap">
<textarea
ref={textareaRef}
className={`quick-entry-input ${isExpanded ? "quick-entry-input--expanded" : ""}`}
className={`quick-entry-input ${isExpanded && !singleLine ? "quick-entry-input--expanded" : ""}`}
placeholder={isSubmitting ? t("tasks.creating", "Creating...") : t("tasks.addTaskPlaceholder", "Add a task...")}
value={description}
onChange={(e) => setDescription(e.target.value)}
@@ -1494,7 +1506,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
onBlur={handleBlur}
disabled={isSubmitting || isDisabled}
data-testid="quick-entry-input"
rows={2}
rows={singleLine ? 1 : 2}
aria-controls="quick-entry-controls"
aria-expanded={isDisclosureExpanded}
/>

View File

@@ -196,10 +196,15 @@ Floating panel positioned by state-driven inline `left/top/width/height`. min/ma
/*
FNXC:RightDock 2026-06-22-17:40:
Header is the drag handle; grab/grabbing cursor and non-selectable text signal and protect the drag.
FNXC:RightDock 2026-06-22-18:50:
Touch dragging was janky because the browser claimed the header's touch stream for scroll/pan gestures. `touch-action: none` on the drag handle (matching the resize handles) hands the whole gesture to our pointer handlers so a finger drag stays smooth and never scrolls the page behind it. `cursor: grab/grabbing` is desktop-only signal; `touch-action` is what makes touch work. A comfortable `min-height` makes the header a forgiving touch target.
*/
.right-dock-expand-modal__header--draggable {
cursor: grab;
user-select: none;
touch-action: none;
min-height: 44px;
}
.right-dock-expand-modal__header--draggable:active {

View File

@@ -147,11 +147,16 @@ export function RightDockExpandModal({
/*
FNXC:RightDock 2026-06-22-17:40:
Header drag: pointerdown on the title bar moves the panel via state-driven `position: fixed; left/top`. Pointer capture keeps the drag alive past the header bounds, updates are rAF-batched so the move stays smooth, and the panel is clamped on-screen. Clicks on the close button are excluded so dragging never swallows the close.
FNXC:RightDock 2026-06-22-18:50:
Touch smoothness fix: listen for pointermove/up on the CAPTURED element (`captureTarget` = event.currentTarget) rather than `document`. `setPointerCapture` redirects every move for this pointerId to that element, so element-scoped listeners receive the full stream even when the finger drifts off the header — and they pair cleanly with `touch-action: none` (CSS) without a separate non-passive document listener. clientX/clientY are read from the captured pointer's move events. Raw moves are coalesced into a single rAF (`frame`) so we set left/top at most once per frame and never thrash layout on a flood of touch-move events.
*/
const handleFloatingDragPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest("button")) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startPosition = position;
@@ -163,6 +168,7 @@ export function RightDockExpandModal({
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
latest = { x: startPosition.x + moveEvent.clientX - startX, y: startPosition.y + moveEvent.clientY - startY };
if (frame) return;
frame = requestAnimationFrame(() => {
@@ -170,29 +176,31 @@ export function RightDockExpandModal({
setPositionState(clampExpandPosition(latest, currentSize));
});
};
const handlePointerUp = () => {
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistPosition(latest, currentSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-drag teardown cancels the rAF and drops the listeners without persisting a partial move.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, position, size]);
/*
@@ -202,7 +210,9 @@ export function RightDockExpandModal({
const handleFloatingResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>, direction: ExpandResizeDirection) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId);
const captureTarget = event.currentTarget;
const pointerId = event.pointerId;
captureTarget.setPointerCapture?.(pointerId);
const startX = event.clientX;
const startY = event.clientY;
const startSize = size;
@@ -215,6 +225,7 @@ export function RightDockExpandModal({
let frame = 0;
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) return;
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
const nextSize = clampExpandSize({
@@ -234,30 +245,32 @@ export function RightDockExpandModal({
setPositionState(clampExpandPosition(latestPosition, latestSize));
});
};
const handlePointerUp = () => {
const detachListeners = () => {
captureTarget.releasePointerCapture?.(pointerId);
captureTarget.removeEventListener("pointermove", handlePointerMove);
captureTarget.removeEventListener("pointerup", handlePointerUp);
captureTarget.removeEventListener("pointercancel", handlePointerUp);
};
function handlePointerUp() {
if (frame) cancelAnimationFrame(frame);
persistSize(latestSize);
persistPosition(latestPosition, latestSize);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
}
// FNXC:RightDock 2026-06-22-17:40: Close/unmount-mid-resize teardown.
dragTeardownRef.current = () => {
if (frame) cancelAnimationFrame(frame);
document.body.style.userSelect = previousUserSelect;
document.removeEventListener("pointermove", handlePointerMove);
document.removeEventListener("pointerup", handlePointerUp);
document.removeEventListener("pointercancel", handlePointerUp);
detachListeners();
dragTeardownRef.current = null;
};
document.addEventListener("pointermove", handlePointerMove);
document.addEventListener("pointerup", handlePointerUp);
document.addEventListener("pointercancel", handlePointerUp);
captureTarget.addEventListener("pointermove", handlePointerMove);
captureTarget.addEventListener("pointerup", handlePointerUp);
captureTarget.addEventListener("pointercancel", handlePointerUp);
}, [persistPosition, persistSize, position, size]);
// FNXC:RightDock 2026-06-22-17:40: Run any active drag/resize teardown on unmount so document pointer listeners + a pending rAF never outlive the modal.

View File

@@ -2026,18 +2026,23 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
}
/*
FNXC:GitManager 2026-06-22-17:30:
The dock tab strip WRAPS so every section is visible at once (no single-tab horizontal swipe). Tabs take intrinsic width — width:auto overrides the base .gm-nav-item width:100% that otherwise made each tab fill the row (one per swipe) — and are compact icon+label so all ~7 sections fit across 2-3 wrapped rows.
FNXC:GitManager 2026-06-22-19:20:
The dock tab strip is ONE ROW that scrolls left-right when needed, showing as many section icons as fit. Tabs are compact ICON-ONLY (labels are sr-only; the button title gives a tooltip) so the maximum number of sections is visible before horizontal scroll kicks in. width:auto overrides the base .gm-nav-item width:100% that otherwise made each tab fill the row (one per swipe).
*/
.gm-modal--embedded .gm-sidebar {
flex: 0 0 auto;
flex-direction: row;
flex-wrap: wrap;
flex-wrap: nowrap;
width: 100%;
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--border);
overflow: visible;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
scrollbar-width: thin;
touch-action: pan-x;
padding: var(--space-xs) var(--space-sm);
gap: var(--space-xs);
}
@@ -2045,15 +2050,26 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
.gm-modal--embedded .gm-nav-item {
flex: 0 0 auto;
width: auto;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
padding: var(--space-xs) var(--space-sm);
align-items: center;
justify-content: center;
gap: 0;
padding: var(--space-xs);
border-left: none;
border-bottom: 2px solid transparent;
font-size: var(--font-size-xs);
min-width: calc(var(--space-2xl) + var(--space-sm));
text-align: center;
justify-content: center;
min-width: calc(var(--space-xl) + var(--space-xs));
}
/* Icon-only: hide the section label (kept for screen readers); the button title is the tooltip. */
.gm-modal--embedded .gm-nav-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.gm-modal--embedded .gm-nav-item.active {
@@ -2061,6 +2077,21 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
border-bottom-color: var(--todo);
}
/*
FNXC:GitManager 2026-06-22-19:20:
In the one-row scrolling strip the refresh is a compact icon button matching .gm-nav-item, pinned as the last item. Drop the desktop margin-top:auto so it stays inline.
*/
.gm-modal--embedded .gm-nav-refresh {
flex: 0 0 auto;
width: auto;
margin-top: 0;
align-items: center;
justify-content: center;
padding: var(--space-xs);
border-left: none;
min-width: calc(var(--space-xl) + var(--space-xs));
}
.gm-modal--embedded .gm-content {
min-height: 200px;
padding: var(--space-md);
@@ -2231,6 +2262,37 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
font-weight: 500;
}
/*
FNXC:GitManager 2026-06-22-19:00:
Refresh button pinned at the end of the section nav strip (replaces the removed duplicate internal gray .modal-header refresh). In the desktop vertical sidebar margin-top:auto pins it to the bottom; in the dock wrapping/mobile horizontal strip it sits as the last tab. Shares the .gm-nav-item visual language; theme tokens only.
*/
.gm-nav-refresh {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
margin-top: auto;
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
width: 100%;
border-left: 3px solid transparent;
}
.gm-nav-refresh:hover:not(:disabled) {
color: var(--text);
background: color-mix(in srgb, var(--text) 5%, transparent);
}
.gm-nav-refresh:disabled {
opacity: 0.6;
cursor: default;
}
/* ── Header Actions ── */
.gm-header-actions {
@@ -4200,6 +4262,25 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
border-bottom-color: var(--todo);
}
/*
FNXC:GitManager 2026-06-22-19:00:
Mobile standalone modal horizontal nav strip: refresh mirrors the mobile .gm-nav-item (column, intrinsic width) as the last tab; drop desktop margin-top:auto.
*/
.gm-nav-refresh {
flex: 0 0 auto;
width: auto;
margin-top: 0;
flex-direction: column;
gap: calc(var(--space-xs) / 2);
padding: var(--space-xs) var(--space-sm);
border-left: none;
font-size: var(--font-size-xs);
min-width: calc(var(--space-2xl) + var(--space-xl));
min-height: calc(var(--space-xl) + var(--space-sm));
text-align: center;
justify-content: center;
}
.gm-content {
min-height: 200px;
padding: var(--space-md);
@@ -4320,6 +4401,11 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
background: var(--surface-hover);
}
/* FNXC:GitManager 2026-06-22-19:00: relocated refresh hover matches nav-item in light theme. */
[data-theme="light"] .gm-nav-refresh:hover:not(:disabled) {
background: var(--surface-hover);
}
[data-theme="light"] .gm-nav-item.active {
background: color-mix(in srgb, var(--todo) 6%, transparent);
}

View File

@@ -20,6 +20,34 @@ The standalone Secrets page is mounted as a flex item inside the .project-conten
gap: var(--space-md);
}
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
SecretsView is a right-dock tool with no --embedded variant; it renders directly inside the dock body
(.right-dock__body) and inside the pop-out (.right-dock-expand-modal__body). In both, the chrome already labels the
view — the dock tab strip names it, and the pop-out's RightDockExpandModal supplies its own header — so the view's own
.secrets-header title row (the "Secrets" heading plus Refresh/Add actions) is redundant title chrome there. Hide it in
those two host contexts only; the header stays in the DOM (just display:none). The standalone full-page render and the
Settings-modal SecretsSection render are NOT inside these ancestors, so their header stays visible.
*/
.right-dock__body .secrets-view > .secrets-header,
.right-dock-expand-modal__body .secrets-view > .secrets-header {
display: none;
}
/*
FNXC:RightDockEmbedded 2026-06-22-19:05:
On real mobile-narrow the view goes full-screen with no dock tab strip or pop-out header, so it must own its title
again. The viewport @media (max-width:768px) fires only on a true narrow viewport (never in the desktop dock/pop-out,
where the @container right-dock-body query drives layout instead), so restoring the header here brings the title back
exactly when the surrounding chrome is gone.
*/
@media (max-width: 768px) {
.right-dock__body .secrets-view > .secrets-header,
.right-dock-expand-modal__body .secrets-view > .secrets-header {
display: flex;
}
}
.secrets-header h2 {
margin: 0;
}

View File

@@ -1057,8 +1057,62 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
margin: 0;
}
/*
FNXC:TaskDetail 2026-06-22-18:40:
Embedded in the full-width board-card panel the detail content must be width-bounded so it cannot overflow and get clipped on the right. Constrain the embedded root and its header/body to the host width (width:100%; min-width:0; max-width:100%); min-width:0 lets inner flex rows (header title row, tabs, action bars) shrink and wrap instead of forcing horizontal overflow. Vertical scroll stays on .detail-body.
*/
.task-detail-content--embedded {
height: 100%;
width: 100%;
min-width: 0;
max-width: 100%;
}
.task-detail-content--embedded .modal-header,
.task-detail-content--embedded .detail-body,
.task-detail-content--embedded .detail-tabs,
.task-detail-content--embedded .modal-actions {
width: 100%;
min-width: 0;
max-width: 100%;
}
/* The gray header row must wrap (task id left, Back-to-board right) instead of overflowing on narrow panels. */
.task-detail-content--embedded .modal-header {
flex-wrap: wrap;
row-gap: var(--space-xs);
}
.task-detail-content--embedded .detail-title-row {
min-width: 0;
}
.task-detail-content--embedded .modal-header-actions {
min-width: 0;
}
/*
FNXC:TaskDetail 2026-06-22-18:40:
"Back to board" affordance inside the gray header. margin-left:auto pushes it to the far right (across from the task id on the left); it shares the header-actions row but stays pinned right and wraps when space is tight. Theme tokens only.
*/
.task-detail-header-back-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
margin-left: auto;
padding: var(--space-xs) var(--space-sm);
font-size: 13px;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
white-space: nowrap;
}
.task-detail-header-back-btn:hover {
color: var(--text);
background: var(--card-hover);
}
.task-detail-content--embedded .detail-actions-menu,

View File

@@ -409,6 +409,11 @@ export interface TaskDetailModalProps {
export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
embedded?: boolean;
onRequestClose?: () => void;
/*
FNXC:TaskDetail 2026-06-22-18:40:
onBackToBoard powers the board-card full-panel "Back to board" affordance rendered in the gray header (far right). It is only honored when embedded is also true, so ListView split-pane and modal usages never show it.
*/
onBackToBoard?: () => void;
};
function truncate(s: string, max: number): string {
@@ -583,6 +588,7 @@ export function TaskDetailContent({
mobileHeaderMode = "close",
embedded = false,
onRequestClose,
onBackToBoard,
workflowFieldDefs: workflowFieldDefsProp,
}: TaskDetailContentProps) {
const { t } = useTranslation("app");
@@ -2734,6 +2740,20 @@ export function TaskDetailContent({
</span>
</div>
<div className="modal-header-actions">
{/*
FNXC:TaskDetail 2026-06-22-18:40:
Board-card full-panel "Back to board" affordance lives here on the far right of the gray header (across from the task id on the left), pushed by margin-left:auto so it never overlaps the id and wraps on narrow widths. Only rendered when embedded AND onBackToBoard are supplied (board-card detail), never in ListView split-pane or modal usages.
*/}
{embedded && onBackToBoard && (
<button
type="button"
className="task-detail-header-back-btn"
onClick={onBackToBoard}
>
<ArrowLeft size={14} aria-hidden="true" />
<span>{t("app.taskDetail.backToBoard", "Back to board")}</span>
</button>
)}
{!isEditing && canEdit && (
<button
className="modal-edit-btn"

View File

@@ -810,7 +810,8 @@ describe("GitHubImportModal", () => {
expect(previewCard.textContent).not.toContain(`${"P".repeat(200)}…`);
});
it("truncates long selected issue body on desktop", async () => {
// FNXC:GitHubImport 2026-06-22-18:30: Desktop preview must show the FULL issue/PR body (no 200-char clamp). The list response already carries the complete body, so no detail fetch is needed.
it("renders long selected issue body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
@@ -834,12 +835,14 @@ describe("GitHubImportModal", () => {
fireEvent.click(screen.getByRole("radio", { name: /Select issue #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(`${"I".repeat(200)}…`);
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(longBody);
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(`${"I".repeat(200)}…`);
// Body renders as markdown via the shared MailboxMessageContent surface.
expect(screen.getByTestId("github-import-preview-body")).toBeTruthy();
});
it("truncates long selected pull request body on desktop", async () => {
it("renders long selected pull request body in full on desktop without a truncation ellipsis", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
@@ -865,9 +868,52 @@ describe("GitHubImportModal", () => {
fireEvent.click(screen.getByRole("radio", { name: /Select pull request #1/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(previewCard.textContent).toContain(`${"R".repeat(200)}…`);
expect(previewCard.textContent).not.toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(longBody);
expect(previewCard.textContent).toContain(longBody);
expect(previewCard.textContent).toContain(beyondDesktopCutoff);
expect(previewCard.textContent).not.toContain(`${"R".repeat(200)}…`);
expect(screen.getByTestId("github-import-preview-body")).toBeTruthy();
});
// FNXC:GitHubImport 2026-06-22-18:30: Full-issue preview must surface key metadata (state, author, GitHub URL) alongside the full markdown body.
it("renders full issue metadata (state, author, GitHub link) in the desktop preview", async () => {
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1200,
});
const issues = [
{
number: 7,
title: "Metadata Issue",
body: "**bold** issue body with `code`",
html_url: "https://github.com/owner/repo/issues/7",
labels: [{ name: "bug" }],
state: "open" as const,
author: "octocat",
},
];
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(singleRemote);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
await waitFor(() => {
expect(screen.getByText("Metadata Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio", { name: /Select issue #7/i }));
const previewCard = await screen.findByTestId("github-import-preview-card");
expect(within(previewCard).getByText("open")).toBeTruthy();
expect(within(previewCard).getByText(/octocat/)).toBeTruthy();
expect(within(previewCard).getByText("bug")).toBeTruthy();
const link = within(previewCard).getByRole("link", { name: /View on GitHub/i }) as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues/7");
// Markdown is rendered (bold/code become elements, not literal asterisks/backticks).
const body = screen.getByTestId("github-import-preview-body");
expect(body.querySelector("strong")).toBeTruthy();
expect(body.querySelector("code")).toBeTruthy();
});
it("returns to list view on mobile after successful import", async () => {

View File

@@ -408,6 +408,32 @@ describe("QuickEntryBox", () => {
expect((textarea as HTMLTextAreaElement).rows).toBe(2);
});
// FNXC:QuickEntry 2026-06-22-19:25: List view passes singleLine so quick-add is a compact one-line input (not the tall 80px auto-grow variant).
describe("singleLine (List view compact mode)", () => {
it("renders a one-line textarea that is not expanded and does not grow on focus/typing", () => {
renderQuickEntryBox({ singleLine: true });
const box = screen.getByTestId("quick-entry-box");
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
expect(box.className).toContain("quick-entry--single-line");
expect(textarea.rows).toBe(1);
// Never the tall expanded variant — even after focus (which auto-expands when not singleLine).
expect(textarea.className).not.toContain("quick-entry-input--expanded");
fireEvent.focus(textarea);
expect(textarea.className).not.toContain("quick-entry-input--expanded");
fireEvent.change(textarea, { target: { value: "line one\nline two\nline three" } });
expect(textarea.className).not.toContain("quick-entry-input--expanded");
});
it("keeps the default tall/expandable behavior when singleLine is not passed (Board/columns)", () => {
renderQuickEntryBox({ singleLine: false });
const box = screen.getByTestId("quick-entry-box");
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
expect(box.className).not.toContain("quick-entry--single-line");
expect(textarea.rows).toBe(2);
});
});
describe("post-submission focus restoration (FN-6217)", () => {
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
mockDesktopViewport();

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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";
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
@@ -266,7 +267,10 @@ describe("RightDock", () => {
it("drags the floating pop-out by its header and clamps + persists the new position", () => {
/*
FNXC:RightDock 2026-06-22-17:40:
Pointerdown on the header drag handle then pointermove on the document moves the panel via state-driven fixed left/top, and pointerup persists the clamped position. Assert the panel moved and that a position was persisted (clamped on-screen).
Pointerdown on the header drag handle then pointermove moves the panel via state-driven fixed left/top, and pointerup persists the clamped position. Assert the panel moved and that a position was persisted (clamped on-screen).
FNXC:RightDock 2026-06-22-18:50:
Move/up are now dispatched on the captured handle element (not document) because the handler attaches its pointermove/up/cancel listeners to the captured target — setPointerCapture redirects the touch stream there, which is what makes touch dragging smooth.
*/
render(
<RightDockExpandModal
@@ -278,8 +282,8 @@ describe("RightDock", () => {
const handle = screen.getByTestId("right-dock-expand-drag-handle");
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 100, clientY: 100 });
fireEvent.pointerMove(document, { pointerId: 1, clientX: 60, clientY: 140 });
fireEvent.pointerUp(document, { pointerId: 1, clientX: 60, clientY: 140 });
fireEvent.pointerMove(handle, { pointerId: 1, clientX: 60, clientY: 140 });
fireEvent.pointerUp(handle, { pointerId: 1, clientX: 60, clientY: 140 });
const persisted = window.localStorage.getItem("fusion:right-dock-expand-modal-position");
expect(persisted).not.toBeNull();
@@ -299,4 +303,62 @@ describe("RightDock", () => {
fireEvent.click(screen.getByTestId("right-dock-expand"));
expect(onExpand).toHaveBeenCalledWith("git-manager");
});
/*
FNXC:RightDock 2026-06-22-18:50:
The popped-out expand modal is independent of the dock's open state. This drives the real controller, pops out a view, then toggles the dock closed and asserts the floating modal is STILL mounted and interactive — only its own close button dismisses it. Guards against the regression where toggling the dock cleared expandedView (and where the modal was a child of the dock that early-returns null when closed).
*/
it("keeps the popped-out expand modal mounted when the dock is toggled closed", () => {
const controllerInput = {
active: true,
projectId: "project-1",
addToast: vi.fn(),
settingsLoaded: true,
researchReadinessVersion: 0,
tasks: [],
workflowSteps: [],
subscribePluginEvents: () => () => {},
openDetailTask: vi.fn(),
openFileInBrowser: vi.fn(),
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 (
<>
<button type="button" data-testid="harness-toggle-dock" onClick={controller.toggle}>
toggle dock
</button>
{controller.dock}
{controller.modal}
</>
);
}
render(<Harness />);
// Pop out the currently selected (Files) view from the open dock.
fireEvent.click(screen.getByTestId("right-dock-expand"));
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
// Toggle the dock closed: the dock itself unmounts, the floating modal MUST survive.
fireEvent.click(screen.getByTestId("harness-toggle-dock"));
expect(screen.queryByTestId("right-dock")).toBeNull();
expect(screen.getByTestId("right-dock-expand-modal")).toBeInTheDocument();
expect(screen.getByTestId("right-dock-expand-body")).toBeInTheDocument();
// Its own close button still dismisses it.
fireEvent.click(screen.getByTestId("right-dock-expand-close"));
expect(screen.queryByTestId("right-dock-expand-modal")).toBeNull();
});
});

View File

@@ -167,8 +167,20 @@ vi.mock("../../components/TaskDetailModal", () => ({
</div>
),
// FNXC:Navigation 2026-06-22-00:00: Board card clicks now open task detail in the full main panel via TaskDetailContent (not the modal). The mock exposes a stable testid so the embedded-panel popstate tests can assert on the new surface.
TaskDetailContent: ({ task }: { task: { id: string; title?: string } }) => (
// FNXC:TaskDetail 2026-06-22-18:40: "Back to board" moved into TaskDetailContent's gray header (rendered when embedded && onBackToBoard). The mock surfaces that button via onBackToBoard so the panel-dismiss popstate tests still drive the same affordance.
TaskDetailContent: ({
task,
onBackToBoard,
}: {
task: { id: string; title?: string };
onBackToBoard?: () => void;
}) => (
<div data-testid="task-detail-main-panel-content">
{onBackToBoard && (
<button type="button" onClick={onBackToBoard}>
Back to board
</button>
)}
<h2>{task.title ?? task.id}</h2>
</div>
),

View File

@@ -48,6 +48,9 @@ export interface RightDockController {
/*
FNXC:Navigation 2026-06-21-23:40:
The right dock is visible by default and collapses from inside the dock. Keep the persisted open/collapsed state in this controller so App and Header do not need duplicate right-dock toggle wiring.
FNXC:RightDock 2026-06-22-18:50:
The popped-out expand modal is INDEPENDENT of the dock's open state. `expandedView` and the modal it drives live at the controller level (a sibling of `dock`, NOT a child of RightDock — which early-returns null when closed). Toggling the dock closed must therefore NOT clear `expandedView`: once a view is popped out it stays open and interactive even with the dock hidden, and only its own close button (`onClose -> setExpandedView(null)`) dismisses it. We still clear `expandedView` when the surface becomes inactive (project change/teardown) because that unmounts the whole controller surface, not a user dock-hide.
*/
export function useRightDockController(input: RightDockControllerInput): RightDockController {
const [open, setOpen] = useState(readStoredRightDockOpen);
@@ -57,11 +60,23 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
setOpen((current) => {
const next = !current;
persistRightDockOpen(next);
if (!next) setExpandedView(null);
// FNXC:RightDock 2026-06-22-18:50: Do NOT clear expandedView on dock-hide; the floating pop-out is independent and survives the dock closing.
return next;
});
}, []);
/*
FNXC:RightDock 2026-06-22-19:25:
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) => {
setExpandedView(viewKey);
if (viewKey) {
setOpen(false);
persistRightDockOpen(false);
}
}, []);
useEffect(() => {
if (!input.active) setExpandedView(null);
}, [input.active]);
@@ -125,7 +140,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
return {
open,
toggle,
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} onExpand={setExpandedView} /> : null,
dock: input.active ? <RightDock open={open} renderProps={renderProps} visibilityOptions={input.visibilityOptions} footerVisible={input.footerVisible} onExpand={handleExpand} /> : null,
modal: input.active ? <RightDockExpandModal viewKey={expandedView} renderProps={renderProps} visibilityOptions={input.visibilityOptions} onClose={() => setExpandedView(null)} /> : null,
};
}

View File

@@ -3844,42 +3844,27 @@ Toast text must contrast its status background across every dashboard theme and
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board ("Full main panel" design). This layout fills the main content area: a fixed Back-to-board row over a scrollable embedded TaskDetailContent body. Theme tokens only; mobile shell renders it unchanged because the panel just fills its host.
Board card clicks open task detail as a full main-content view that replaces the board ("Full main panel" design). This layout fills the main content area with a scrollable embedded TaskDetailContent body. Theme tokens only; mobile shell renders it unchanged because the panel just fills its host.
FNXC:TaskDetail 2026-06-22-18:40:
The panel and its body must be width-bounded (width:100%; min-width:0; max-width:100%) so the embedded detail content cannot exceed the full-width host and get clipped on the right; the body scrolls vertically only (overflow-x:hidden). The separate back-row was removed; "Back to board" now lives inside TaskDetailContent's gray header (see .task-detail-header-back-btn).
*/
.task-detail-main-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.task-detail-main-panel-back-row {
flex: 0 0 auto;
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.task-detail-main-panel-back-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
font-size: 13px;
color: var(--text-muted);
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.task-detail-main-panel-back-btn:hover {
color: var(--text);
background: var(--card-hover);
width: 100%;
min-width: 0;
max-width: 100%;
}
.task-detail-main-panel-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: hidden;
overflow-y: auto;
}

View File

@@ -3055,6 +3055,7 @@ export class GitHubClient {
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
author?: string | null;
}>> {
if (this.hasGhAuth()) {
try {
@@ -3085,6 +3086,7 @@ export class GitHubClient {
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
author?: string | null;
}>> {
const limit = options?.limit ?? 30;
const state = options?.state ?? "open";
@@ -3098,12 +3100,14 @@ export class GitHubClient {
labels: Array<{ name: string }>;
state: "OPEN" | "CLOSED";
updatedAt: string;
author?: { login?: string } | null;
}>>([
"issue", "list",
"--repo", `${owner}/${repo}`,
"--state", state,
"--limit", String(Math.min(limit, 100)),
"--json", "number,title,body,url,labels,state,updatedAt",
// FNXC:GitHubImport 2026-06-22-18:30: Request `author` so the import preview pane can show full issue metadata (author/state alongside the already-present full body) without a per-item detail fetch.
"--json", "number,title,body,url,labels,state,updatedAt,author",
]);
let result = issues.map((issue) => ({
@@ -3114,6 +3118,7 @@ export class GitHubClient {
labels: issue.labels,
state: this.mapGhIssueState(issue.state),
updatedAt: issue.updatedAt,
author: issue.author?.login ?? null,
}));
// Filter by labels if specified (client-side filtering)
@@ -3140,6 +3145,7 @@ export class GitHubClient {
labels: Array<{ name: string }>;
state?: "open" | "closed";
updatedAt?: string;
author?: string | null;
}>> {
const limit = options?.limit ?? 30;
const state = options?.state ?? "open";
@@ -3171,6 +3177,7 @@ export class GitHubClient {
labels: Array<{ name: string }>;
state: string;
updated_at: string;
user?: { login?: string } | null;
pull_request?: unknown;
}>;
@@ -3185,6 +3192,7 @@ export class GitHubClient {
labels: issue.labels,
state: this.mapIssueState(issue.state),
updatedAt: issue.updated_at,
author: issue.user?.login ?? null,
}))
.slice(0, limit);
}
@@ -3443,6 +3451,8 @@ export class GitHubClient {
html_url: string;
headBranch: string;
baseBranch: string;
state?: "open" | "closed" | "merged";
author?: string | null;
}>> {
if (this.hasGhAuth()) {
try {
@@ -3472,6 +3482,8 @@ export class GitHubClient {
html_url: string;
headBranch: string;
baseBranch: string;
state?: "open" | "closed" | "merged";
author?: string | null;
}>> {
const limit = options?.limit ?? 30;
@@ -3482,12 +3494,15 @@ export class GitHubClient {
url: string;
headRefName: string;
baseRefName: string;
state?: "OPEN" | "CLOSED" | "MERGED";
author?: { login?: string } | null;
}>>([
"pr", "list",
"--repo", `${owner}/${repo}`,
"--state", "open",
"--limit", String(Math.min(limit, 100)),
"--json", "number,title,body,url,headRefName,baseRefName",
// FNXC:GitHubImport 2026-06-22-18:30: Request `state,author` so the import preview pane shows full PR metadata (author/state with the already-present full body) without a per-item detail fetch.
"--json", "number,title,body,url,headRefName,baseRefName,state,author",
]);
return pulls.map((pr) => ({
@@ -3497,6 +3512,8 @@ export class GitHubClient {
html_url: pr.url,
headBranch: pr.headRefName,
baseBranch: pr.baseRefName,
state: pr.state ? (pr.state.toLowerCase() as "open" | "closed" | "merged") : undefined,
author: pr.author?.login ?? null,
}));
}
@@ -3511,6 +3528,8 @@ export class GitHubClient {
html_url: string;
headBranch: string;
baseBranch: string;
state?: "open" | "closed" | "merged";
author?: string | null;
}>> {
const limit = options?.limit ?? 30;
@@ -3537,6 +3556,8 @@ export class GitHubClient {
html_url: string;
head: { ref: string };
base: { ref: string };
state?: string;
user?: { login?: string } | null;
}>;
return data.slice(0, limit).map((pr) => ({
@@ -3546,6 +3567,8 @@ export class GitHubClient {
html_url: pr.html_url,
headBranch: pr.head.ref,
baseBranch: pr.base.ref,
state: pr.state === "open" || pr.state === "closed" ? pr.state : undefined,
author: pr.user?.login ?? null,
}));
}