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

@@ -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", () => {