feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list

Phase 6 (subset) of multi-repository task support. The kanban now
exposes the per-task repository list to the UI and renders a compact
"+N" badge on cards whose tasks span more than one repository.

- KanbanState.tasks[].repositories slim shape (id, repository_id,
  base_branch, checkout_branch, position).
- SSR mapper hydrates the full list and uses primaryTaskRepository()
  to pick the position-aware primary instead of [0].
- useKanbanData passes repositories through to the card props.
- KanbanCard renders the primary repo name with a small "+N" chip
  when len(repositories) > 1; tooltip names the count.
- useTaskRepositories / useTaskPrimaryRepository hooks return the
  slim KanbanTaskRepository shape so multi-repo consumers share one
  read path.

Larger UI surfaces (task-create dialog, diff view, file browser,
mobile sheets) still target the primary repo only — they will be
ported as the multi-repo task creation flow lands. No visible change
for single-repo tasks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kandev Agent
2026-04-26 00:39:33 +01:00
parent 4fe4773381
commit fa86328ec9
5 changed files with 47 additions and 7 deletions
+12 -2
View File
@@ -42,6 +42,8 @@ export interface Task {
description?: string;
position?: number;
repositoryId?: string;
/** All repositories linked to the task; used to render a "+N" chip for multi-repo. */
repositories?: Array<{ id: string; repository_id: string; position: number }>;
// Workflow fields
sessionCount?: number | null;
primarySessionId?: string | null;
@@ -93,8 +95,16 @@ function KanbanCardBody({
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{repoName && (
<p className="text-[10px] mb-1 text-muted-foreground leading-tight truncate">
{repoName}
<p className="text-[10px] mb-1 text-muted-foreground leading-tight truncate flex items-center gap-1">
<span className="truncate">{repoName}</span>
{task.repositories && task.repositories.length > 1 && (
<span
className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80"
title={`Task spans ${task.repositories.length} repositories`}
>
+{task.repositories.length - 1}
</span>
)}
</p>
)}
<div className="flex items-center gap-1 min-w-0">
@@ -71,6 +71,7 @@ export function useKanbanData({
description: task.description,
position: task.position,
repositoryId: task.repositoryId,
repositories: task.repositories,
primarySessionId: task.primarySessionId,
sessionCount: task.sessionCount,
reviewStatus: task.reviewStatus,
@@ -3,7 +3,14 @@
import { useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import type { KanbanState } from "@/lib/state/slices";
import { primaryTaskRepository, type TaskRepository } from "@/lib/types/http";
/**
* Slim per-task repository shape carried in the kanban store. Mirrors
* KanbanState["tasks"][number]["repositories"][number].
*/
export type KanbanTaskRepository = NonNullable<
KanbanState["tasks"][number]["repositories"]
>[number];
/**
* Returns the repositories linked to a task, ordered by Position. Empty
@@ -11,7 +18,7 @@ import { primaryTaskRepository, type TaskRepository } from "@/lib/types/http";
* use this instead of poking task.repositories directly so multi-repo
* consumers all share one source of truth.
*/
export function useTaskRepositories(taskId: string | null | undefined): TaskRepository[] {
export function useTaskRepositories(taskId: string | null | undefined): KanbanTaskRepository[] {
const task = useAppStore((state) =>
taskId
? (state.kanban.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId) ?? null)
@@ -28,7 +35,7 @@ export function useTaskRepositories(taskId: string | null | undefined): TaskRepo
*/
export function useTaskPrimaryRepository(
taskId: string | null | undefined,
): TaskRepository | null {
): KanbanTaskRepository | null {
const repos = useTaskRepositories(taskId);
return primaryTaskRepository(repos) ?? null;
return repos.length > 0 ? repos[0] : null;
}
+10 -1
View File
@@ -1,4 +1,5 @@
import type { AppState, KanbanState } from "@/lib/state/store";
import { primaryTaskRepository } from "@/lib/types/http";
import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http";
import {
isPRReviewFromMetadata,
@@ -26,6 +27,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
.map((task) => {
const workflowStepId = task.workflow_step_id;
if (!workflowStepId) return null;
const primary = primaryTaskRepository(task.repositories);
return {
id: task.id,
workflowStepId,
@@ -33,7 +35,14 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
repositoryId: primary?.repository_id ?? undefined,
repositories: task.repositories?.map((r) => ({
id: r.id,
repository_id: r.repository_id,
base_branch: r.base_branch,
checkout_branch: r.checkout_branch,
position: r.position,
})),
primarySessionId: task.primary_session_id ?? undefined,
primarySessionState: task.primary_session_state ?? undefined,
sessionCount: task.session_count ?? undefined,
+13
View File
@@ -28,7 +28,20 @@ export type KanbanState = {
description?: string;
position: number;
state?: TaskStatus;
/** Primary repository id (lowest position). Kept for backwards compat. */
repositoryId?: string;
/**
* All repositories linked to the task, ordered by Position. Optional so
* legacy SSR payloads still parse; multi-repo UI consumers should prefer
* this over repositoryId.
*/
repositories?: Array<{
id: string;
repository_id: string;
base_branch: string;
checkout_branch?: string;
position: number;
}>;
primarySessionId?: string | null;
primarySessionState?: string | null;
sessionCount?: number | null;