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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user