Consolidate task overview and show branch status in sidebar

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-09-09 13:07:09 -04:00
parent 9ae8f2a7a5
commit 3b5a5f4752
8 changed files with 221 additions and 371 deletions

View File

@@ -1,352 +1,60 @@
import { useQuery } from "@tanstack/react-query";
import { GitBranch, GitPullRequest, MessagesSquare } from "lucide-react";
import * as React from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import * as React from "react";
import { toast } from "sonner";
import { useCanvasQuery } from "@/features/channels/hooks";
import {
githubRepositoryUrl,
parseChannelBackedTask,
type ChannelBackedTask,
} from "@/features/channels/lib/channelBackedTask";
import { getProjectRepoDiff } from "@/shared/api/projectGit";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Button } from "@/shared/ui/button";
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
import { useTaskBranchStatus } from "@/features/channels/useTaskBranchStatus";
import { cn } from "@/shared/lib/cn";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { Markdown } from "@/shared/ui/markdown";
import { invokeTauri } from "@/shared/api/tauri";
type GithubReview = {
number: number;
title: string;
url: string;
state: string;
isDraft: boolean;
reviewDecision: string;
statusCheckRollup:
| {
name?: string;
context?: string;
detailsUrl?: string;
targetUrl?: string;
conclusion?: string;
status?: string;
state?: string;
}[]
| null;
};
type GithubChanges = {
html_url: string;
files: {
filename: string;
additions: number;
deletions: number;
patch?: string;
blob_url: string;
}[];
};
function useGithubTask(task: ChannelBackedTask, view: "changes" | "review") {
const repository = task.branch && githubRepositoryUrl(task.branch.repository);
return useQuery({
queryKey: ["task-github", repository, task.branch?.name, view],
enabled: Boolean(repository && task.branch),
queryFn: () =>
invokeTauri<GithubChanges | GithubReview[]>("get_task_github", {
repository: repository?.replace("https://github.com/", ""),
branch: task.branch?.name,
view,
}),
staleTime: 30_000,
retry: false,
});
}
type TaskView = "overview" | "changes" | "review" | "conversation";
const VIEWS: readonly TaskView[] = [
"overview",
"changes",
"review",
"conversation",
];
function TaskLink({ href, children }: { href: string; children: string }) {
return (
<a
className="text-primary hover:underline"
href={href}
target="_blank"
rel="noreferrer"
onClick={(event) => {
event.preventDefault();
void openUrl(href).catch(() =>
toast.error("Could not open link in browser"),
);
}}
>
{children}
</a>
);
}
function TaskOverview({ task }: { task: ChannelBackedTask }) {
const repository = task.branch && githubRepositoryUrl(task.branch.repository);
return (
<div
className="mx-auto w-full max-w-3xl space-y-6 px-6 py-8"
data-testid="task-overview"
>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Task
</p>
<h2 className="text-xl font-semibold tracking-tight">
{task.task.title}
</h2>
<p className="text-sm leading-6 text-muted-foreground">
{task.task.description}
</p>
</div>
<dl className="grid gap-4 rounded-xl border border-border/70 bg-muted/20 p-4 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted-foreground">Origin</dt>
<dd className="mt-1">
<Markdown content={task.originatingThread} />
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Parent</dt>
<dd className="mt-1">
<Markdown content={task.parentChannel} />
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted-foreground">
Implementation branch
</dt>
<dd className="mt-1 font-mono text-xs">
{repository && task.branch ? (
<TaskLink
href={`${repository}/tree/${encodeURIComponent(task.branch.name)}`}
>
{task.branch.name}
</TaskLink>
) : (
(task.branch?.name ?? "Not started")
)}
</dd>
</div>
</dl>
</div>
);
}
function TaskChanges({ task }: { task: ChannelBackedTask }) {
const branch = task.branch;
const github = useGithubTask(task, "changes");
const isGithub = Boolean(branch && githubRepositoryUrl(branch.repository));
const diff = useQuery({
enabled: Boolean(branch) && !isGithub,
queryKey: ["channel-backed-task", branch?.repository, branch?.name, "diff"],
queryFn: () =>
getProjectRepoDiff({
cloneUrl: branch?.repository ?? "",
defaultBranch: branch?.name,
baseBranch: "main",
}),
retry: 1,
staleTime: 30_000,
});
if (isGithub) {
if (github.isPending)
return <BuzzLoadingState label="Loading GitHub changes" />;
if (github.error)
return (
<EmptyView
title="Could not load GitHub changes"
description={github.error.message}
/>
);
const changes = github.data as GithubChanges;
return (
<div className="mx-auto w-full max-w-4xl space-y-4 p-6">
<TaskLink href={changes.html_url}>
{branch?.name ?? "Branch changes"}
</TaskLink>
{changes.files.map((file) => (
<details key={file.filename} className="rounded border p-3" open>
<summary className="cursor-pointer font-mono text-xs">
{file.filename}{" "}
<span className="text-green-600">+{file.additions}</span>{" "}
<span className="text-red-600">−{file.deletions}</span>
</summary>
{file.patch ? (
<pre className="mt-3 overflow-x-auto whitespace-pre font-mono text-xs">
{file.patch}
</pre>
) : (
<TaskLink href={file.blob_url}>View file on GitHub</TaskLink>
)}
</details>
))}
<p className="text-xs text-muted-foreground">
GitHub returns patches for up to 300 files. The branch link opens the
complete comparison.
</p>
</div>
);
}
if (!branch)
return (
<EmptyView
title="No implementation branch"
description="Changes will appear after a branch is attached to this task."
/>
);
if (diff.isLoading)
return <BuzzLoadingState label="Loading branch changes" />;
if (diff.error || !diff.data)
return (
<EmptyView
title="Could not load changes"
description={
diff.error instanceof Error
? diff.error.message
: "The branch is not available from this repository."
}
/>
);
return (
<div
className="mx-auto w-full max-w-3xl space-y-4 px-6 py-8"
data-testid="task-changes"
>
<div className="flex flex-wrap items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<code className="text-sm">{branch.name}</code>
<span className="text-xs text-muted-foreground">against main</span>
<span className="ml-auto text-xs">
<span className="text-green-600">+{diff.data.additions}</span>{" "}
<span className="text-red-600">−{diff.data.deletions}</span>
</span>
</div>
<div className="divide-y overflow-hidden rounded-xl border border-border/70">
{diff.data.files.map((file) => (
<div
className="flex items-center gap-3 px-4 py-3 text-sm"
key={file.path}
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{file.path}
</span>
<span className="text-xs text-green-600">+{file.additions}</span>
<span className="text-xs text-red-600">−{file.deletions}</span>
</div>
))}
</div>
</div>
);
}
function TaskReview({ task }: { task: ChannelBackedTask }) {
const github = useGithubTask(task, "review");
const repository = task.branch
? githubRepositoryUrl(task.branch.repository)
: null;
const reviewsUrl =
repository && task.branch
? `${repository}/pulls?q=${encodeURIComponent(`is:pr head:${task.branch.name}`)}`
: null;
if (repository && task.branch) {
if (github.isPending)
return <BuzzLoadingState label="Finding pull request" />;
if (github.error)
return (
<EmptyView
title="Could not load pull request"
description={github.error.message}
/>
);
const prs = github.data as GithubReview[];
if (!prs.length)
return (
<EmptyView
title="No pull request yet"
description={`No pull request found for ${task.branch.name}.`}
/>
);
const pr = prs.find((pr) => pr.state === "OPEN") ?? prs[0];
return (
<div className="mx-auto w-full max-w-3xl space-y-4 p-6">
<h2 className="text-lg font-semibold">
<TaskLink href={pr.url}>{`#${pr.number} ${pr.title}`}</TaskLink>
</h2>
<p className="text-sm">
{pr.isDraft ? "Draft" : pr.state} ·{" "}
{pr.reviewDecision || "No review decision"}
</p>
<ul className="space-y-2 text-sm">
{pr.statusCheckRollup?.map((check) => (
<li
key={
check.detailsUrl ??
check.targetUrl ??
check.name ??
check.context
}
>
{check.name ?? check.context}:{" "}
{check.conclusion || check.state || check.status}
</li>
))}
</ul>
</div>
);
}
return (
<EmptyView
action={
reviewsUrl ? (
<Button asChild size="sm" variant="outline">
<a href={reviewsUrl} rel="noreferrer" target="_blank">
Open GitHub reviews
</a>
</Button>
) : undefined
}
description="Review data is not linked to this channel yet. This view is where checks, findings, and decisions will appear."
icon={<GitPullRequest className="h-5 w-5" />}
title="No linked review"
/>
);
}
function EmptyView({
action,
description,
icon,
title,
function BranchStatus({
branch,
}: {
action?: React.ReactNode;
description: string;
icon?: React.ReactNode;
title: string;
branch: NonNullable<ChannelBackedTask["branch"]>;
}) {
const repository = githubRepositoryUrl(branch.repository);
const { query, pr, Icon, label: status, color } = useTaskBranchStatus(branch);
const href =
pr?.url ??
(repository
? `${repository}/tree/${encodeURIComponent(branch.name)}`
: null);
const label = pr ? `#${pr.number} ${pr.title}` : branch.name;
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-10 text-center">
<div className="max-w-md space-y-3">
{icon ? (
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon}
</div>
) : null}
<h2 className="text-base font-semibold">{title}</h2>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
{action}
<div className="mt-1 space-y-1 text-xs">
<div className="flex items-center gap-2">
<Icon aria-label={status} className={cn("h-4 w-4 shrink-0", color)} />
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer"
className="min-w-0 truncate text-muted-foreground hover:text-foreground hover:underline"
title={`${status}: ${label}`}
onClick={(event) => {
event.preventDefault();
void openUrl(href).catch(() =>
toast.error("Could not open link in browser"),
);
}}
>
{label}
</a>
) : (
<span>{label}</span>
)}
</div>
{query.isFetching && <span className="sr-only">Checking for PR…</span>}
{query.error && (
<p role="status" className="truncate" title={query.error.message}>
Could not check PR
</p>
)}
</div>
);
}
@@ -359,38 +67,33 @@ export function TaskChannelWorkspace({
children: React.ReactNode;
}) {
const canvas = useCanvasQuery(channelId, channelId !== null);
const task = React.useMemo(
() => parseChannelBackedTask(canvas.data?.content),
[canvas.data?.content],
const task = parseChannelBackedTask(canvas.data?.content);
const [view, setView] = React.useState<"overview" | "conversation">(
"overview",
);
const [view, setView] = React.useState<TaskView>("overview");
if (!task) return children;
return (
<div className="flex min-h-0 flex-1 flex-col">
<nav
aria-label="Task views"
className={cn(
channelChrome.contentPadding,
"flex gap-1 border-b border-border/70 px-5 pb-2",
"flex gap-1 border-b px-5 pb-2",
)}
data-testid="task-view-tabs"
>
{VIEWS.map((item) => (
{(["overview", "conversation"] as const).map((item) => (
<button
aria-current={view === item ? "page" : undefined}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium capitalize text-muted-foreground hover:bg-muted hover:text-foreground",
view === item && "bg-muted text-foreground",
)}
data-testid={`task-view-${item}`}
key={item}
onClick={() => setView(item)}
type="button"
aria-current={view === item ? "page" : undefined}
data-testid={`task-view-${item}`}
className={cn(
"rounded-full px-3 py-1.5 text-xs capitalize hover:bg-muted",
view === item && "bg-muted",
)}
onClick={() => setView(item)}
>
{item === "conversation" ? (
<MessagesSquare className="mr-1.5 inline h-3.5 w-3.5" />
) : null}
{item}
</button>
))}
@@ -398,14 +101,25 @@ export function TaskChannelWorkspace({
{view === "conversation" ? (
children
) : (
<div className="flex min-h-0 flex-1 overflow-y-auto">
{view === "overview" ? (
<TaskOverview task={task} />
) : view === "changes" ? (
<TaskChanges task={task} />
) : (
<TaskReview task={task} />
)}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className="mx-auto max-w-3xl space-y-6 px-6 py-8"
data-testid="task-overview"
>
<h2 className="text-xl font-semibold">{task.task.title}</h2>
<p className="text-sm text-muted-foreground">
{task.task.description}
</p>
<div className="space-y-2 text-sm">
<div>
Origin <Markdown content={task.originatingThread} />
</div>
<div>
Parent <Markdown content={task.parentChannel} />
</div>
</div>
{task.branch && <BranchStatus branch={task.branch} />}
</div>
</div>
)}
</div>

View File

@@ -1,10 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { taskParentChannel } from "@/features/channels/lib/channelBackedTask";
import {
taskParentChannel,
parseChannelBackedTask,
type ChannelBackedTask,
} from "@/features/channels/lib/channelBackedTask";
import { getCanvases } from "@/shared/api/tauri";
import type { Channel } from "@/shared/api/types";
export const TaskChannelBranches = React.createContext(
new Map<string, NonNullable<ChannelBackedTask["branch"]>>(),
);
export function useChannelBackedTaskParents(channels: Channel[]) {
const channelIds = React.useMemo(
() => channels.map((channel) => channel.id).sort(),
@@ -19,10 +27,18 @@ export function useChannelBackedTaskParents(channels: Channel[]) {
return React.useMemo(() => {
const parents = new Map<string, string>();
const branches = new Map<
string,
NonNullable<ChannelBackedTask["branch"]>
>();
for (const channelId of channelIds) {
const parentId = taskParentChannel(canvases.data?.[channelId]?.content);
if (parentId) parents.set(channelId, parentId);
const branch = parseChannelBackedTask(
canvases.data?.[channelId]?.content,
)?.branch;
if (branch) branches.set(channelId, branch);
}
return parents;
return { parents, branches };
}, [canvases.data, channelIds]);
}

View File

@@ -0,0 +1,66 @@
import { useQuery } from "@tanstack/react-query";
import {
GitBranch,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
} from "lucide-react";
import {
githubRepositoryUrl,
type ChannelBackedTask,
} from "@/features/channels/lib/channelBackedTask";
import { invokeTauri } from "@/shared/api/tauri";
type PullRequest = {
number: number;
title: string;
url: string;
state: string;
isDraft: boolean;
};
export function useTaskBranchStatus(
branch: NonNullable<ChannelBackedTask["branch"]>,
) {
const repository = githubRepositoryUrl(branch.repository);
const query = useQuery({
queryKey: ["task-github", repository, branch.name, "review"],
enabled: Boolean(repository),
queryFn: () =>
invokeTauri<PullRequest[]>("get_task_github", {
repository: repository?.replace("https://github.com/", ""),
branch: branch.name,
view: "review",
}),
staleTime: 60_000,
retry: false,
});
const pr =
query.data?.find((item) => item.state === "OPEN") ?? query.data?.[0];
const Icon = !pr
? GitBranch
: pr.state === "MERGED"
? GitMerge
: pr.state === "CLOSED"
? GitPullRequestClosed
: GitPullRequest;
const label = !pr
? "Branch"
: pr.state === "MERGED"
? "Merged PR"
: pr.state === "CLOSED"
? "Closed PR"
: pr.isDraft
? "Draft PR"
: "Open PR";
const color = !pr
? "text-muted-foreground"
: pr.state === "MERGED"
? "text-purple-500"
: pr.state === "CLOSED"
? "text-red-500"
: pr.isDraft
? "text-muted-foreground"
: "text-green-500";
return { query, pr, Icon, label, color };
}

View File

@@ -76,7 +76,10 @@ import {
useSidebar,
} from "@/shared/ui/sidebar";
import { useProtectedVisibleDirectMessages } from "@protected-feature-components";
import { useChannelBackedTaskParents } from "@/features/channels/useChannelBackedTaskParents";
import {
useChannelBackedTaskParents,
TaskChannelBranches,
} from "@/features/channels/useChannelBackedTaskParents";
export function AppSidebar({
addCommunityPrefill,
@@ -309,7 +312,8 @@ export function AppSidebar({
() => channels.filter((channel) => channel.channelType === "stream"),
[channels],
);
const taskParentByChannelId = useChannelBackedTaskParents(streamChannels);
const { parents: taskParentByChannelId, branches: taskBranches } =
useChannelBackedTaskParents(streamChannels);
const sectionBuckets = React.useMemo(() => {
const bySection: Record<string, Channel[]> = {};
@@ -514,7 +518,7 @@ export function AppSidebar({
[assignChannel, onBrowseChannels],
);
return (
const sidebar = (
<Sidebar
className="!z-[100] !border-r-0"
collapsible="offcanvas"
@@ -974,4 +978,9 @@ export function AppSidebar({
<SidebarRail />
</Sidebar>
);
return (
<TaskChannelBranches.Provider value={taskBranches}>
{sidebar}
</TaskChannelBranches.Provider>
);
}

View File

@@ -1,5 +1,6 @@
import type * as React from "react";
import { BellOff, ChevronDown, CircleDot, X } from "lucide-react";
import { TaskChannelStatusGlyph } from "./TaskChannelStatusGlyph";
import {
ContextMenu,
@@ -358,6 +359,7 @@ export function ChannelMenuButton({
{hasThreadUnread ? (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
) : null}
<TaskChannelStatusGlyph channelId={channel.id} />
</SidebarMenuButton>
);

View File

@@ -0,0 +1,32 @@
import { useContext } from "react";
import { TaskChannelBranches } from "@/features/channels/useChannelBackedTaskParents";
import { useTaskBranchStatus } from "@/features/channels/useTaskBranchStatus";
import type { ChannelBackedTask } from "@/features/channels/lib/channelBackedTask";
import { cn } from "@/shared/lib/cn";
function BranchGlyph({
branch,
}: {
branch: NonNullable<ChannelBackedTask["branch"]>;
}) {
const { query, pr, Icon, label, color } = useTaskBranchStatus(branch);
const description = `${label}: ${branch.name}${pr ? ` (#${pr.number})` : ""}${query.error ? " — PR status unavailable" : query.isFetching ? " — checking PR status" : ""}`;
return (
<span
title={description}
className="ml-auto flex shrink-0"
data-testid="task-channel-status"
>
<Icon
role="img"
aria-label={description}
className={cn("h-3.5 w-3.5", color)}
/>
</span>
);
}
export function TaskChannelStatusGlyph({ channelId }: { channelId: string }) {
const branch = useContext(TaskChannelBranches).get(channelId);
return branch ? <BranchGlyph branch={branch} /> : null;
}

View File

@@ -14,7 +14,7 @@ branch:
name: "jtennant/berd-voice-status-sounds"
---`;
test("a task canvas turns one channel into task, changes, review, and conversation views", async ({
test("a task canvas exposes overview and conversation only", async ({
page,
}) => {
await installMockBridge(page, { canvasContent: TASK_CANVAS });
@@ -24,14 +24,15 @@ test("a task canvas turns one channel into task, changes, review, and conversati
await expect(page.getByTestId("task-overview")).toContainText(
"Add status sounds to Berd Voice",
);
await page.getByTestId("task-view-changes").click();
await expect(page.getByTestId("task-changes")).toContainText(
"ProjectDetailScreen.tsx",
await expect(page.getByTestId("task-view-changes")).toHaveCount(0);
await expect(page.getByTestId("task-view-review")).toHaveCount(0);
await expect(page.getByTestId("task-overview")).toContainText(
"jtennant/berd-voice-status-sounds",
);
await page.getByTestId("task-view-review").click();
await expect(page.getByText("No linked review")).toBeVisible();
await expect(page.getByTestId("task-work-tree")).toHaveCount(0);
await page.getByTestId("task-view-conversation").click();
await expect(page.getByTestId("message-timeline")).toBeVisible();
await expect(page.getByTestId("task-work-navigator")).toHaveCount(0);
});
test("task canvases nest channels beneath their parents", async ({ page }) => {
@@ -51,6 +52,12 @@ test("task canvases nest channels beneath their parents", async ({ page }) => {
const list = page.getByTestId("stream-list");
await expect(list.getByTestId("channel-buzz")).toBeVisible();
await expect(
list.getByTestId("channel-buzz").getByTestId("task-channel-status"),
).toHaveCount(0);
await expect(
list.getByTestId("channel-engineering").getByTestId("task-channel-status"),
).toBeVisible();
await expect(
list.locator(
'[data-channel-depth="1"] [data-testid="channel-engineering"]',
@@ -59,4 +66,8 @@ test("task canvases nest channels beneath their parents", async ({ page }) => {
await expect(
list.locator('[data-channel-depth="2"] [data-testid="channel-agents"]'),
).toBeVisible();
await list.getByTestId("channel-buzz").click();
await expect(page.getByTestId("task-work-tree")).toHaveCount(0);
await expect(page.getByTestId("task-view-changes")).toHaveCount(0);
await expect(page.getByTestId("task-view-review")).toHaveCount(0);
});

View File

@@ -16,7 +16,7 @@ Conversation → local task + new channel → implementation branch → pull req
- A branch created outside this workflow can remain unbound temporarily and should be flagged for resolution. A person or agent can attach it to an appropriate channel or create a new one.
- Rebinding an established branch is for correcting mistakes, not routine restructuring.
- The branch, pull request, CI, and review use the task's channel.
- The client recognizes the canvas schema and adds Overview, Changes, Review, and Conversation views to that channel. GitHub branches use the local `gh` login to load patches and discover their pull request, review decision, and CI checks. Buzz-hosted branches use the relay's diff support.
- The client recognizes the canvas schema and adds Overview and Conversation views. Overview shows the selected task and its branch or pull request. The nested left sidebar shows branch and PR status glyphs, using the local `gh` login to discover pull requests.
## Canvas data model