feat(FN-3423): show task branch metadata on cards

- Render Branch/Base chips in TaskCard when branch metadata is present
- Add responsive TaskCard styles for branch metadata chips using design tokens
- Expand TaskCard tests to cover branch/base rendering and memo comparator branch changes
- Remove unused ChatView focus-preservation refs/callback to resolve lint blockers
- Document branch metadata semantics in task-management docs

Fusion-Task-Id: FN-3423
This commit is contained in:
Fusion
2026-05-06 21:14:16 -07:00
committed by gsxdsm
parent 6de69a2ced
commit a0fa7f7bc8
5 changed files with 154 additions and 28 deletions

View File

@@ -110,6 +110,8 @@ fn task unarchive FN-001
### Branch metadata semantics
Task cards on the board surface branch metadata when present so users can scan both the working branch and merge target without opening task details.
Task branch fields are intentionally distinct:
- `task.branch` — the actual working branch used for the task worktree (for example `fusion/fn-1234` or a conflict-suffixed variant).

View File

@@ -772,7 +772,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const preserveComposerFocusRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const mentionCursorPosRef = useRef(0);
@@ -1068,25 +1067,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
sendMessage,
]);
const focusComposerInput = useCallback(() => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
const input = inputRef.current;
if (!input || input.disabled) return;
const previousScrollX = window.scrollX;
const previousScrollY = window.scrollY;
input.focus({ preventScroll: true });
// iOS can still jump layout viewport on focus changes even with preventScroll.
// Restore scroll position on the next frame to keep the thread anchored.
window.requestAnimationFrame(() => {
if (window.scrollX !== previousScrollX || window.scrollY !== previousScrollY) {
window.scrollTo(previousScrollX, previousScrollY);
}
});
}, []);
const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => {
setMessageInput((currentInput) => {
@@ -1332,13 +1312,6 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
);
const handleInputBlur = useCallback(() => {
if (preserveComposerFocusRef.current) {
window.requestAnimationFrame(() => {
focusComposerInput();
});
return;
}
if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current);
}
@@ -1352,7 +1325,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
fileMention.dismissMention();
hideSkillMenuTimeoutRef.current = null;
}, 120);
}, [fileMention, focusComposerInput]);
}, [fileMention]);
const handleInputFocus = useCallback(() => {
if (hideSkillMenuTimeoutRef.current !== null) {

View File

@@ -79,6 +79,43 @@
word-break: break-word;
}
.card-branch-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-xs);
}
.card-branch-chip {
display: inline-flex;
align-items: center;
min-width: 0;
gap: var(--space-xs);
padding: calc(var(--space-xs) / 4) calc(var(--space-sm) - (var(--space-xs) / 4));
border-radius: var(--radius-pill);
border: var(--btn-border-width) solid color-mix(in srgb, var(--text-muted) 35%, transparent);
background: color-mix(in srgb, var(--text-muted) 14%, transparent);
color: var(--text-muted);
font-size: 0.625rem;
}
.card-branch-label {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
flex-shrink: 0;
}
.card-branch-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
color: var(--text);
}
/* z-index + position: relative so .card-dep-badge tooltip renders above .card-title */
.card-meta {
display: flex;
@@ -1080,6 +1117,18 @@
padding: calc(var(--space-xs) / 4) var(--space-sm);
}
.card-branch-row {
gap: calc(var(--space-xs) / 2);
}
.card-branch-chip {
max-width: 100%;
}
.card-branch-value {
max-width: 18ch;
}
/* Card: wrap dependency badges */
.card-dep-list {
flex-wrap: wrap;

View File

@@ -407,6 +407,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.size === nextTask.size &&
previousTask.blockedBy === nextTask.blockedBy &&
previousTask.worktree === nextTask.worktree &&
previousTask.branch === nextTask.branch &&
previousTask.baseBranch === nextTask.baseBranch &&
previousTask.breakIntoSubtasks === nextTask.breakIntoSubtasks &&
previousTask.currentStep === nextTask.currentStep &&
@@ -709,6 +710,7 @@ function TaskCardComponent({
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isGitHubImportedTask = task.sourceType === "github_import";
const hasBranchMetadata = Boolean(task.branch || task.baseBranch);
const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const isAgentCreated = isAgentCreatedTask(task);
const sourceAgentName = getSourceAgentName(task);
@@ -1494,6 +1496,22 @@ function TaskCardComponent({
<div className="card-title" title={task.title || task.description || undefined}>
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
</div>
{hasBranchMetadata && (
<div className="card-branch-row" aria-label="Branch metadata">
{task.branch && (
<span className="card-branch-chip" title={task.branch}>
<span className="card-branch-label">Branch</span>
<span className="card-branch-value">{task.branch}</span>
</span>
)}
{task.baseBranch && (
<span className="card-branch-chip" title={task.baseBranch}>
<span className="card-branch-label">Base</span>
<span className="card-branch-value">{task.baseBranch}</span>
</span>
)}
</div>
)}
{showProgressSection && (() => {
const progressPercent = (unifiedProgress.completed / unifiedProgress.total) * 100;
return (

View File

@@ -171,6 +171,78 @@ describe("TaskCard", () => {
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("renders branch and base branch metadata when both are present", () => {
const { container } = render(
<TaskCard
task={makeTask({ branch: "feature/fn-3423-card-branches", baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const branchRow = container.querySelector(".card-branch-row");
expect(branchRow).not.toBeNull();
expect(screen.getByText("Branch")).toBeDefined();
expect(screen.getByText("feature/fn-3423-card-branches")).toBeDefined();
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("main")).toBeDefined();
});
it("renders only working branch metadata when baseBranch is absent", () => {
render(
<TaskCard
task={makeTask({ branch: "feature/working-only", baseBranch: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Branch")).toBeDefined();
expect(screen.getByText("feature/working-only")).toBeDefined();
expect(screen.queryByText("Base")).toBeNull();
});
it("renders only base branch metadata when branch is absent", () => {
render(
<TaskCard
task={makeTask({ branch: undefined, baseBranch: "release/2026-05" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("Base")).toBeDefined();
expect(screen.getByText("release/2026-05")).toBeDefined();
expect(screen.queryByText("Branch")).toBeNull();
});
it("does not render branch metadata row when both branch fields are absent", () => {
const { container } = render(
<TaskCard
task={makeTask({ branch: undefined, baseBranch: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-branch-row")).toBeNull();
});
it("keeps long branch names readable via text and title semantics", () => {
const longBranch = "feature/fn-3423-display-very-long-working-branch-name-for-card-metadata";
const { container } = render(
<TaskCard
task={makeTask({ branch: longBranch, baseBranch: "main" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
const branchChip = container.querySelector(".card-branch-chip");
expect(branchChip?.getAttribute("title")).toBe(longBranch);
expect(screen.getByText(longBranch)).toBeDefined();
});
it("renders fast-mode indicator only when executionMode is fast", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ executionMode: "fast" })} onOpenDetail={noop} addToast={noop} />,
@@ -1336,6 +1408,18 @@ describe("TaskCard memo comparator provenance behavior", () => {
),
).toBe(false);
});
it("returns false when branch changes", () => {
const previousTask = makeTask({ branch: "feature/old", baseBranch: "main" });
const nextTask = makeTask({ branch: "feature/new", baseBranch: "main" });
expect(
__test_areTaskCardPropsEqual(
{ task: previousTask, onOpenDetail: noop, addToast: noop } as any,
{ task: nextTask, onOpenDetail: noop, addToast: noop } as any,
),
).toBe(false);
});
});
describe("TaskCard mission badge", () => {