feat(panel): dense tooltip pass — work surfaces (270 tips)

Task lifecycle actions get a 25-action tip map, every dialog field and
git panel control annotated; fixes two round-1 disabled-button tips
that could never fire and a double data-state stamp on tab-progress.
This commit is contained in:
Renn F
2026-07-15 18:04:00 +02:00
parent f9dbb30a4e
commit dcd2f84e69
56 changed files with 2554 additions and 1546 deletions
+20 -10
View File
@@ -3,6 +3,7 @@
import { useEffect, useRef } from "react";
import { Loader2, Sparkles, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { HelpTip } from "@/components/ui/help-tip";
import { usePrompter } from "@/hooks/use-prompter";
import { Team } from "@/types";
import {
@@ -77,16 +78,25 @@ export default function PrompterPage() {
</div>
{/* End chat — reap the agent and return to the form (any chat state) */}
{!showForm && state !== "success" && (
<Button
variant="ghost"
size="sm"
className="ml-auto text-muted-foreground"
onClick={startAnother}
disabled={state === "launching"}
>
<X className="mr-1 h-4 w-4" />
End chat
</Button>
<HelpTip label="Stops the agent session and clears this chat — you'll return to the intake form">
{/* disabled sets pointer-events:none on the Button, which would
swallow hover — the tip sits on a wrapping span instead. */}
<span
className="ml-auto"
tabIndex={state === "launching" ? undefined : 0}
>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={startAnother}
disabled={state === "launching"}
>
<X className="mr-1 h-4 w-4" />
End chat
</Button>
</span>
</HelpTip>
)}
</div>
@@ -29,6 +29,7 @@ import { AlertTriangle, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { HelpTip } from "@/components/ui/help-tip";
interface TaskDetailPageProps {
params: Promise<{ taskId: string }>;
@@ -415,12 +416,14 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
if (error || !task) {
return (
<div className="space-y-6">
<Link href="/tasks" prefetch={false}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Tasks
</Button>
</Link>
<HelpTip label="Returns to the task list, preserving whatever filters/sort/page you had set there.">
<Link href="/tasks" prefetch={false}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Tasks
</Button>
</Link>
</HelpTip>
<Card>
<CardContent className="pt-6">
@@ -432,9 +435,11 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
"The task you're looking for doesn't exist or has been deleted."}
</p>
<div className="flex justify-center gap-4">
<Link href="/tasks" prefetch={false}>
<Button>View All Tasks</Button>
</Link>
<HelpTip label="Leaves this not-found error and returns to the full task list.">
<Link href="/tasks" prefetch={false}>
<Button>View All Tasks</Button>
</Link>
</HelpTip>
</div>
</div>
</CardContent>
@@ -474,12 +479,14 @@ export default function TaskDetailPage({ params }: TaskDetailPageProps) {
task.team !== Team.MAIN_PM && (
<div className="flex justify-end gap-2">
{task.team === Team.BOARD && (
<Button
variant="outline"
onClick={() => router.push(`/prompter?redraft=${task.id}`)}
>
Re-draft with board feedback
</Button>
<HelpTip label="Re-opens the intake chat, threading in the board's review notes so the draft can be revised.">
<Button
variant="outline"
onClick={() => router.push(`/prompter?redraft=${task.id}`)}
>
Re-draft with board feedback
</Button>
</HelpTip>
)}
<ApproveAndStartButton task={task} />
</div>
+6 -3
View File
@@ -18,6 +18,7 @@ import type { TaskFilters as TaskApiFilters } from "@/lib/api/tasks";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageRefresh } from "@/hooks";
import { useScrollRestorationStore } from "@/lib/stores";
import { HelpTip } from "@/components/ui/help-tip";
function TasksPageContent() {
const router = useRouter();
@@ -304,9 +305,11 @@ function TasksPageContent() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tasks</h1>
<p className="text-muted-foreground">
Manage and track all tasks across teams
</p>
<HelpTip label="Filters, sort, and pagination all persist in the URL — this view is bookmarkable and shareable.">
<p className="text-muted-foreground">
Manage and track all tasks across teams
</p>
</HelpTip>
</div>
<div className="flex items-center gap-2">
<CreateTaskDialog />
@@ -71,6 +71,9 @@ describe("CodeSnippet", () => {
// The active line's content is "b"; its row carries the highlight class.
const row = screen.getByText("b").parentElement;
expect(row?.className).toContain("bg-blue-500/15");
// The active line's number is now a real HelpTip trigger (Radix injects
// data-state via asChild), not a bare span.
expect(screen.getByText("2").getAttribute("data-state")).toBe("closed");
});
it("renders the fail-open hint on an error", () => {
+12 -7
View File
@@ -4,6 +4,7 @@ import { useGitFile } from "@/hooks/use-git";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface CodeSnippetProps {
branch: string | null | undefined;
@@ -64,9 +65,11 @@ export function CodeSnippet({
"bg-blue-500/15 ring-1 ring-inset ring-blue-500/30",
)}
>
<span className="select-none w-8 shrink-0 text-right text-muted-foreground/60">
{lineNo}
</span>
<HelpTip label={isActive ? "The line flagged in this finding" : ""}>
<span className="select-none w-8 shrink-0 text-right text-muted-foreground/60">
{lineNo}
</span>
</HelpTip>
<span className="whitespace-pre">{text || " "}</span>
</div>
);
@@ -74,10 +77,12 @@ export function CodeSnippet({
</pre>
</ScrollArea>
{data.truncated && (
<p className="border-t px-2 py-1 text-[10px] text-muted-foreground">
showing lines {data.start_line}{data.start_line + lines.length - 1}{" "}
of {data.total_lines}
</p>
<HelpTip label="Only a window around the flagged line is loaded, not the whole file — widen `context` to see more.">
<p className="border-t px-2 py-1 text-[10px] text-muted-foreground w-fit">
showing lines {data.start_line}
{data.start_line + lines.length - 1} of {data.total_lines}
</p>
</HelpTip>
)}
</div>
);
+181 -100
View File
@@ -150,28 +150,51 @@ export function GitActionsPanel({
open={showCommitDialog}
onOpenChange={handleCommitDialogOpenChange}
>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant={hasStagedChanges ? "default" : "outline"}
disabled={!hasStagedChanges}
{/* HelpTip+span wraps the whole DialogTrigger, not the Button
itself — the Button stays DialogTrigger's asChild target so
Radix's Slot merge is undisturbed, and the outer span keeps the
tip hoverable even while disabled (disabled sets
pointer-events:none on the button, which would swallow hover on
anything nested inside it). */}
<HelpTip
label={
hasStagedChanges
? "Records the staged files as a local commit — doesn't push to the remote."
: "Nothing staged yet — stage files first before a commit can be made."
}
>
<span
className="block w-full"
tabIndex={!hasStagedChanges ? 0 : undefined}
>
<GitCommit className="h-4 w-4 mr-2" />
Commit Changes
{hasStagedChanges && (
<Badge variant="secondary" className="ml-auto">
{status?.staged_files.length} files
</Badge>
)}
</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant={hasStagedChanges ? "default" : "outline"}
disabled={!hasStagedChanges}
>
<GitCommit className="h-4 w-4 mr-2" />
Commit Changes
{hasStagedChanges && (
<Badge variant="secondary" className="ml-auto">
{status?.staged_files.length} files
</Badge>
)}
</Button>
</DialogTrigger>
</span>
</HelpTip>
<DialogContent>
<DialogHeader>
<DialogTitle>Commit Changes</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Commit Message</label>
<HelpTip label="Becomes the commit's message — shows up in the log panel and the PR's commit history.">
<label className="text-sm font-medium w-fit">
Commit Message
</label>
</HelpTip>
<Textarea
placeholder="Describe your changes..."
value={commitMessage}
@@ -216,52 +239,82 @@ export function GitActionsPanel({
</Dialog>
{/* Push Action */}
<Button
className="w-full justify-start"
variant={canPush ? "default" : "outline"}
disabled={!canPush || isPushing}
onClick={() => onPush(false)}
<HelpTip
label={
canPush
? "git push -u origin <branch>. Uploads your local commits to GitHub — never force, safe to repeat."
: "Nothing to push — no local commits sit ahead of the remote branch yet."
}
>
{isPushing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Push to Remote
{status?.ahead !== undefined && status.ahead > 0 && (
<HelpTip label="Commits not yet pushed to the remote repository">
<Badge variant="secondary" className="ml-auto">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead}
</Badge>
</HelpTip>
)}
</Button>
<span
className="block w-full"
tabIndex={!canPush ? 0 : undefined}
>
<Button
className="w-full justify-start"
variant={canPush ? "default" : "outline"}
disabled={!canPush || isPushing}
onClick={() => onPush(false)}
>
{isPushing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Push to Remote
{status?.ahead !== undefined && status.ahead > 0 && (
<HelpTip label="Commits not yet pushed to the remote repository">
<Badge variant="secondary" className="ml-auto">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead}
</Badge>
</HelpTip>
)}
</Button>
</span>
</HelpTip>
{/* Create PR Action */}
<Dialog open={showPRDialog} onOpenChange={handlePRDialogOpenChange}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={!canCreatePR}
<HelpTip
label={
canCreatePR
? "Opens GitHub's PR creation flow for this branch against main."
: "Already on main with nothing ahead of it — there's no branch content to open a PR for."
}
>
<span
className="block w-full"
tabIndex={!canCreatePR ? 0 : undefined}
>
<GitPullRequest className="h-4 w-4 mr-2" />
Create Pull Request
</Button>
</DialogTrigger>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={!canCreatePR}
>
<GitPullRequest className="h-4 w-4 mr-2" />
Create Pull Request
</Button>
</DialogTrigger>
</span>
</HelpTip>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Pull Request</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{status?.current_branch}</Badge>
<span></span>
<Badge variant="outline">main</Badge>
</div>
<HelpTip label="Source branch on the left merges into the target branch on the right.">
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
<Badge variant="outline">{status?.current_branch}</Badge>
<span></span>
<Badge variant="outline">main</Badge>
</div>
</HelpTip>
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<HelpTip label="Becomes the pull request's title on GitHub.">
<label className="text-sm font-medium w-fit">Title</label>
</HelpTip>
<Input
placeholder="PR title..."
value={prTitle}
@@ -269,7 +322,11 @@ export function GitActionsPanel({
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<HelpTip label="The PR's body on GitHub — markdown supported, shown to reviewers.">
<label className="text-sm font-medium w-fit">
Description
</label>
</HelpTip>
<Textarea
placeholder="Describe your changes..."
value={prBody}
@@ -300,23 +357,31 @@ export function GitActionsPanel({
open={showMergeDialog}
onOpenChange={handleMergeDialogOpenChange}
>
<DialogTrigger asChild>
<Button className="w-full justify-start" variant="outline">
{isMerging ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitMerge className="h-4 w-4 mr-2" />
)}
Merge PR
</Button>
</DialogTrigger>
<HelpTip label="Merges an open PR via GitHub's API — squash by default, falls back to merge/rebase if blocked by branch protection.">
<span className="block w-full">
<DialogTrigger asChild>
<Button className="w-full justify-start" variant="outline">
{isMerging ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitMerge className="h-4 w-4 mr-2" />
)}
Merge PR
</Button>
</DialogTrigger>
</span>
</HelpTip>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Merge Pull Request</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">PR Number</label>
<HelpTip label="The GitHub PR number to merge, e.g. the 42 in .../pull/42.">
<label className="text-sm font-medium w-fit">
PR Number
</label>
</HelpTip>
<Input
type="number"
placeholder="e.g. 42"
@@ -351,51 +416,63 @@ export function GitActionsPanel({
</Dialog>
{/* Pull Action */}
<Button
className="w-full justify-start"
variant="outline"
disabled={isPulling}
onClick={onPull}
>
{isPulling ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Pull from Remote
</Button>
{/* Fetch Action */}
<Button
className="w-full justify-start"
variant="outline"
disabled={isFetching}
onClick={onFetch}
>
{isFetching ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Fetch Remote
</Button>
{/* Rebase Action — destructive, requires confirmation */}
<AlertDialog>
<AlertDialogTrigger asChild>
<HelpTip label="Fetches origin, then fast-forwards your branch onto it. Refuses (no merge commit) if history has diverged.">
<span className="block w-full">
<Button
className="w-full justify-start"
variant="outline"
disabled={isRebasing}
disabled={isPulling}
onClick={onPull}
>
{isRebasing ? (
{isPulling ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitGraph className="h-4 w-4 mr-2" />
<Download className="h-4 w-4 mr-2" />
)}
Rebase onto Remote
Pull from Remote
</Button>
</AlertDialogTrigger>
</span>
</HelpTip>
{/* Fetch Action */}
<HelpTip label="Updates remote-tracking refs from origin only — never touches your working directory or local branch.">
<span className="block w-full">
<Button
className="w-full justify-start"
variant="outline"
disabled={isFetching}
onClick={onFetch}
>
{isFetching ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4 mr-2" />
)}
Fetch Remote
</Button>
</span>
</HelpTip>
{/* Rebase Action — destructive, requires confirmation */}
<AlertDialog>
<HelpTip label="Rewrites this branch's commit history on top of another branch. Requires a force-push afterward — use with care.">
<span className="block w-full">
<AlertDialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={isRebasing}
>
{isRebasing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<GitGraph className="h-4 w-4 mr-2" />
)}
Rebase onto Remote
</Button>
</AlertDialogTrigger>
</span>
</HelpTip>
<AlertDialogContent className="border-destructive bg-destructive/5">
<AlertDialogHeader>
<AlertDialogTitle>Rebase onto target branch?</AlertDialogTitle>
@@ -407,7 +484,11 @@ export function GitActionsPanel({
</AlertDialogDescription>
</AlertDialogHeader>
<div className="px-6 py-2 space-y-2">
<label className="text-sm font-medium">Target branch</label>
<HelpTip label="Branch/ref to replay commits onto, e.g. origin/main. On conflict, rebase auto-aborts — your tree stays untouched.">
<label className="text-sm font-medium w-fit">
Target branch
</label>
</HelpTip>
<Input
placeholder="Remote ref (e.g. origin/HEAD)"
value={rebaseTargetBranch}
+85 -50
View File
@@ -92,19 +92,27 @@ export function GitBranchPanel({
Branches
</span>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-3 w-3 mr-1" />
New
</Button>
</DialogTrigger>
<HelpTip label="Creates a new branch named {type}/{team}/{task-id}, branched from the task's parent branch (or the project default).">
<span className="inline-block">
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-3 w-3 mr-1" />
New
</Button>
</DialogTrigger>
</span>
</HelpTip>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Task Branch</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Branch Type</label>
<HelpTip label="Sets the branch-name prefix (feature/bug/chore/docs/hotfix) — doesn't otherwise change behavior.">
<label className="text-sm font-medium w-fit">
Branch Type
</label>
</HelpTip>
<Select
value={newBranchType}
onValueChange={(v) => setNewBranchType(v as BranchType)}
@@ -122,7 +130,11 @@ export function GitBranchPanel({
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Task ID</label>
<HelpTip label="Identifies the task this branch is for — gets embedded in the branch name (shortened to 8 chars).">
<label className="text-sm font-medium w-fit">
Task ID
</label>
</HelpTip>
<Input
placeholder="Enter task ID..."
value={taskId}
@@ -156,41 +168,56 @@ export function GitBranchPanel({
<div className="space-y-3">
{/* Local Branches */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
Local ({localBranches.length})
</h4>
<HelpTip label="Branches that exist in your local clone's .git — checking one out is instant, no fetch needed.">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 w-fit">
Local ({localBranches.length})
</h4>
</HelpTip>
<div className="space-y-0.5">
{localBranches.map((branch) => (
<Button
<HelpTip
key={branch.name}
onClick={() =>
!branch.is_current && onCheckout(branch.name)
}
disabled={branch.is_current || isCheckingOut}
variant="ghost"
className={
"w-full h-auto justify-between px-2 py-1.5 text-sm font-normal whitespace-normal " +
(branch.is_current
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
: "hover:bg-muted")
label={
branch.is_current
? "This is the branch you're already on."
: "Switches your workspace to this branch (git checkout). Non-conflicting uncommitted changes carry over."
}
>
<div className="flex items-center gap-2 min-w-0">
{branch.is_current && (
<Check className="h-3 w-3 text-primary shrink-0" />
)}
<span className="truncate font-mono text-xs">
{branch.name}
</span>
</div>
{branch.last_commit && (
<HelpTip label="Short commit hash on this branch">
<span className="text-xs text-muted-foreground font-mono shrink-0">
{branch.last_commit.slice(0, 7)}
</span>
</HelpTip>
)}
</Button>
<span
className="block"
tabIndex={branch.is_current ? 0 : undefined}
>
<Button
onClick={() =>
!branch.is_current && onCheckout(branch.name)
}
disabled={branch.is_current || isCheckingOut}
variant="ghost"
className={
"w-full h-auto justify-between px-2 py-1.5 text-sm font-normal whitespace-normal " +
(branch.is_current
? "bg-primary/10 text-primary hover:bg-primary/10 hover:text-primary"
: "hover:bg-muted")
}
>
<div className="flex items-center gap-2 min-w-0">
{branch.is_current && (
<Check className="h-3 w-3 text-primary shrink-0" />
)}
<span className="truncate font-mono text-xs">
{branch.name}
</span>
</div>
{branch.last_commit && (
<HelpTip label="Short commit hash on this branch">
<span className="text-xs text-muted-foreground font-mono shrink-0">
{branch.last_commit.slice(0, 7)}
</span>
</HelpTip>
)}
</Button>
</span>
</HelpTip>
))}
</div>
</div>
@@ -198,23 +225,31 @@ export function GitBranchPanel({
{/* Remote Branches */}
{remoteBranches.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 flex items-center gap-1">
<Cloud className="h-3 w-3" />
Remote ({remoteBranches.length})
</h4>
<HelpTip label="Branches on origin without a local counterpart yet — checking one out creates a local tracking branch.">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 flex items-center gap-1 w-fit">
<Cloud className="h-3 w-3" />
Remote ({remoteBranches.length})
</h4>
</HelpTip>
<div className="space-y-0.5">
{remoteBranches.map((branch) => (
<Button
<HelpTip
key={branch.name}
onClick={() => onCheckout(branch.name)}
disabled={isCheckingOut}
variant="ghost"
className="w-full h-auto justify-start px-2 py-1.5 text-sm font-normal whitespace-normal hover:bg-muted"
label="Creates a local branch tracking this remote ref and switches to it."
>
<span className="truncate font-mono text-xs text-muted-foreground">
{branch.name}
<span className="block">
<Button
onClick={() => onCheckout(branch.name)}
disabled={isCheckingOut}
variant="ghost"
className="w-full h-auto justify-start px-2 py-1.5 text-sm font-normal whitespace-normal hover:bg-muted"
>
<span className="truncate font-mono text-xs text-muted-foreground">
{branch.name}
</span>
</Button>
</span>
</Button>
</HelpTip>
))}
</div>
</div>
+24 -19
View File
@@ -18,6 +18,7 @@ import { GitDiffViewer } from "./git-diff-viewer";
import { GitActionsPanel } from "./git-actions-panel";
import { GitBranch, FolderGit2 } from "lucide-react";
import { useGitBrowser } from "@/hooks/use-git-browser";
import { HelpTip } from "@/components/ui/help-tip";
function GitBrowserContent() {
const {
@@ -80,25 +81,29 @@ function GitBrowserContent() {
</div>
<div className="flex items-center gap-2">
{/* Project Selector */}
<Select value={projectSlug} onValueChange={handleProjectChange}>
<SelectTrigger className="w-full sm:w-64">
<FolderGit2 className="h-4 w-4 mr-2" />
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{loadingProjects ? (
<div className="p-2">
<Skeleton className="h-8 w-full" />
</div>
) : (
projects?.map((p) => (
<SelectItem key={p.id} value={p.slug}>
{p.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<HelpTip label="Switches every panel below (status, branches, log, diff) to this project's clone on disk.">
<span className="w-full sm:w-64">
<Select value={projectSlug} onValueChange={handleProjectChange}>
<SelectTrigger className="w-full sm:w-64">
<FolderGit2 className="h-4 w-4 mr-2" />
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{loadingProjects ? (
<div className="p-2">
<Skeleton className="h-8 w-full" />
</div>
) : (
projects?.map((p) => (
<SelectItem key={p.id} value={p.slug}>
{p.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
</span>
</HelpTip>
</div>
</div>
+23 -14
View File
@@ -104,21 +104,30 @@ export function GitDiffViewer({
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
<Button
variant={wrap ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={wrap}
onClick={() => setWrap((w) => !w)}
title="Toggle line wrap"
<HelpTip label="Green = added lines, red = removed, blue = hunk header (@@ ... @@ shows the surrounding line range).">
<CardTitle className="text-sm flex items-center gap-2 w-fit">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
</HelpTip>
<HelpTip
label={
wrap
? "Long lines wrap to fit the panel — click to scroll horizontally instead."
: "Long lines overflow with horizontal scroll — click to wrap them instead."
}
>
<WrapText className="h-3.5 w-3.5 mr-1" />
Wrap
</Button>
<Button
variant={wrap ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={wrap}
onClick={() => setWrap((w) => !w)}
>
<WrapText className="h-3.5 w-3.5 mr-1" />
Wrap
</Button>
</HelpTip>
</div>
</CardHeader>
<CardContent className="p-0">
+18 -10
View File
@@ -62,17 +62,22 @@ export function GitLogPanel({
<CardTitle className="text-sm flex items-center gap-2">
<GitCommit className="h-4 w-4" />
Commit History
<Badge variant="secondary" className="ml-auto text-xs">
{log.branch}
</Badge>
<HelpTip label="Commits reachable from this branch, newest first.">
<Badge variant="secondary" className="ml-auto text-xs">
{log.branch}
</Badge>
</HelpTip>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-80">
<div className="p-4 space-y-0">
{log.commits.map((commit, index) => (
<Button
<HelpTip
key={commit.hash}
label={onSelectCommit ? "Select this commit" : ""}
>
<Button
onClick={() => onSelectCommit?.(commit)}
variant="ghost"
className={cn(
@@ -120,16 +125,19 @@ export function GitLogPanel({
<User className="h-3 w-3" />
{commit.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDistanceToNow(new Date(commit.date), {
addSuffix: true,
})}
</span>
<HelpTip label={new Date(commit.date).toLocaleString()}>
<span className="flex items-center gap-1 w-fit">
<Calendar className="h-3 w-3" />
{formatDistanceToNow(new Date(commit.date), {
addSuffix: true,
})}
</span>
</HelpTip>
</div>
</div>
</div>
</Button>
</HelpTip>
))}
</div>
</ScrollArea>
+12 -8
View File
@@ -70,20 +70,24 @@ export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
Repository Status
</span>
{!hasChanges && (
<Badge variant="outline" className="text-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
Clean
</Badge>
<HelpTip label="No staged, modified, or untracked files — the working tree matches HEAD.">
<Badge variant="outline" className="text-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
Clean
</Badge>
</HelpTip>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Branch Info */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{status.current_branch}</span>
</div>
<HelpTip label="The branch currently checked out in this project's workspace.">
<div className="flex items-center gap-2 w-fit">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{status.current_branch}</span>
</div>
</HelpTip>
{(status.ahead > 0 || status.behind > 0) && (
<div className="flex items-center gap-2">
{status.ahead > 0 && (
@@ -16,6 +16,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import {
@@ -391,34 +392,54 @@ export function KanbanBoard({
{/* Mobile: single-column view with prev/next navigator (hidden on sm+) */}
<div className="sm:hidden flex flex-1 min-h-0 flex-col">
<div className="flex items-center gap-2 mb-4 shrink-0">
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() => setActiveColumnIndex((i) => Math.max(0, i - 1))}
disabled={activeColumnIndex === 0}
aria-label="Previous column"
>
<ChevronLeft className="h-5 w-5" />
</Button>
{/* Buttons wrapped in a span, not tipped directly: disabled sets
pointer-events:none on the Button and would swallow hover. */}
<HelpTip label="Swipe to the previous lifecycle stage — each column is one task status">
<span
className="shrink-0"
tabIndex={activeColumnIndex === 0 ? undefined : 0}
>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() => setActiveColumnIndex((i) => Math.max(0, i - 1))}
disabled={activeColumnIndex === 0}
aria-label="Previous column"
>
<ChevronLeft className="h-5 w-5" />
</Button>
</span>
</HelpTip>
<p className="flex-1 text-center text-sm font-semibold">
<span className="text-muted-foreground font-normal text-xs mr-1">
{activeColumnIndex + 1}/{columns.length}
</span>
{columns[activeColumnIndex]?.title}
</p>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() =>
setActiveColumnIndex((i) => Math.min(columns.length - 1, i + 1))
}
disabled={activeColumnIndex === columns.length - 1}
aria-label="Next column"
>
<ChevronRight className="h-5 w-5" />
</Button>
<HelpTip label="Swipe to the next lifecycle stage — each column is one task status">
<span
className="shrink-0"
tabIndex={
activeColumnIndex === columns.length - 1 ? undefined : 0
}
>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0"
onClick={() =>
setActiveColumnIndex((i) =>
Math.min(columns.length - 1, i + 1),
)
}
disabled={activeColumnIndex === columns.length - 1}
aria-label="Next column"
>
<ChevronRight className="h-5 w-5" />
</Button>
</span>
</HelpTip>
</div>
{columns[activeColumnIndex] && (
<KanbanColumn
@@ -17,6 +17,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { PriorityIndicator } from "../shared/priority-indicator";
import { BlockedBadge } from "../shared/blocked-badge";
import { AssigneeAvatar } from "../shared/assignee-avatar";
@@ -109,12 +110,13 @@ export function KanbanCard({
<div className="flex-1 min-w-0 overflow-hidden">
<Link href={"/tasks/" + task.id} className="block" prefetch={false}>
<p className="font-medium text-sm line-clamp-2 hover:underline break-words">
<span
className="font-mono text-muted-foreground"
title={task.id}
<HelpTip
label={`Full ID ${task.id} — the 8-char prefix is what commits and branch names for this task use`}
>
#{task.id.slice(0, 8)}
</span>{" "}
<span className="font-mono text-muted-foreground">
#{task.id.slice(0, 8)}
</span>
</HelpTip>{" "}
{task.title}
</p>
</Link>
@@ -144,9 +146,11 @@ export function KanbanCard({
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-xs">
{task.team.replace(/_/g, " ")}
</Badge>
<HelpTip label="Delivery cell that owns this task — only its agents can claim it">
<Badge variant="outline" className="text-xs">
{task.team.replace(/_/g, " ")}
</Badge>
</HelpTip>
<TaskTypeBadge type={task.task_type} showLabel={false} />
{task.sequence != null && (
<Tooltip>
@@ -8,6 +8,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { taskStatusDescription } from "@/components/tasks/task-status-badge";
import { KanbanCard } from "./kanban-card";
import { useDroppable } from "@dnd-kit/core";
import { cn } from "@/lib/utils";
@@ -55,9 +57,19 @@ export function KanbanColumn({
)}
>
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-sm text-gray-800 dark:text-gray-100">
{title}
</h3>
{/* Reuses the canonical per-status copy (task-status-badge.tsx) so a
column header explains what a drag-drop here actually does. */}
<HelpTip
label={
taskStatusDescription(status)
? `Dropping a card here fires this transition: ${taskStatusDescription(status)}`
: null
}
>
<h3 className="font-semibold text-sm text-gray-800 dark:text-gray-100 w-fit">
{title}
</h3>
</HelpTip>
<Tooltip>
<TooltipTrigger asChild>
<Badge
@@ -267,15 +267,17 @@ export function BatchReviewCard({
)}
<div className="flex flex-wrap gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={onKeepChatting}
disabled={isLaunching}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep chatting
</Button>
<HelpTip label="Dismisses this review and returns to chat — nothing is launched, the draft batch is kept">
<Button
variant="outline"
size="sm"
onClick={onKeepChatting}
disabled={isLaunching}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep chatting
</Button>
</HelpTip>
{/* Board review & Start → the Board reviews the whole batch first */}
<HelpTip label="Routes the whole MegaTask through the Product Owner and Head of Marketing for review before any work starts.">
<Button
@@ -78,21 +78,25 @@ export function BoardReviewSentCard({
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link
prefetch={false}
href={`/tasks/${taskId}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View umbrella task
</Link>
</Button>
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start another
</Button>
<HelpTip label="Opens the umbrella task in a new tab — use Approve & Start there once the board finishes">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link
prefetch={false}
href={`/tasks/${taskId}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View umbrella task
</Link>
</Button>
</HelpTip>
<HelpTip label="Ends this session and resets to a fresh intake chat">
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start another
</Button>
</HelpTip>
</CardFooter>
</Card>
);
+13 -10
View File
@@ -8,6 +8,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
@@ -57,16 +58,18 @@ export function ChatComposer({
className="flex items-end gap-2 border-t bg-background px-4 py-3"
style={{ paddingBottom: "max(0.75rem, env(safe-area-inset-bottom))" }}
>
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isSending}
rows={2}
className="flex-1 resize-none text-sm"
/>
<HelpTip label="Enter sends; Shift+Enter inserts a newline">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || isSending}
rows={2}
className="flex-1 resize-none text-sm"
/>
</HelpTip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -7,6 +7,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import type { ChatMessage, StartRoute } from "@/hooks/use-prompter";
import { DraftProposalCard } from "./draft-proposal-card";
@@ -110,7 +111,11 @@ export function ChatMessages({
return (
<div key={msg.id} className="flex justify-start">
<div className="group relative flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<HelpTip label="A request to the agent failed — this message did not come from it">
<span className="mt-0.5 shrink-0" tabIndex={0}>
<AlertTriangle className="h-4 w-4" />
</span>
</HelpTip>
<span>{msg.content}</span>
<CopyButton
value={msg.content}
@@ -88,17 +88,23 @@ export function DraftProposalCard({
</CardTitle>
<div className="flex flex-wrap items-center gap-1 shrink-0">
{draft.team && (
<Badge variant="secondary" className="text-xs">
{draft.team}
</Badge>
<HelpTip label="Delivery cell this task will be routed to">
<Badge variant="secondary" className="text-xs">
{draft.team}
</Badge>
</HelpTip>
)}
<Badge variant="outline" className="text-xs">
{priorityLabel}
</Badge>
{draft.task_type && (
<HelpTip label="How urgently agents work this once created — higher priority is claimed first">
<Badge variant="outline" className="text-xs">
{draft.task_type}
{priorityLabel}
</Badge>
</HelpTip>
{draft.task_type && (
<HelpTip label="Kind of work this is — affects which role picks it up">
<Badge variant="outline" className="text-xs">
{draft.task_type}
</Badge>
</HelpTip>
)}
<CopyButton value={draftToText(draft)} className="ml-0.5" />
</div>
@@ -161,15 +167,17 @@ export function DraftProposalCard({
</CardContent>
<CardFooter className="flex-wrap gap-2 pt-0">
<Button
variant="outline"
size="sm"
onClick={onKeepChatting}
disabled={isLaunching}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep chatting
</Button>
<HelpTip label="Dismisses this card and returns to chat — nothing is launched, the draft is kept">
<Button
variant="outline"
size="sm"
onClick={onKeepChatting}
disabled={isLaunching}
>
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
Keep chatting
</Button>
</HelpTip>
{/* Board review & Start → PENDING, assigned to PO + HoM for review */}
<HelpTip label="Routes this task through the Product Owner and Head of Marketing for review before any work starts.">
<Button
+20 -14
View File
@@ -139,9 +139,11 @@ export function IntakeForm({
onValueChange={onProjectId}
disabled={isPreparing}
>
<SelectTrigger>
<SelectValue placeholder="Select a project…" />
</SelectTrigger>
<HelpTip label="Only this project's repo is cloned and read by the agent">
<SelectTrigger>
<SelectValue placeholder="Select a project…" />
</SelectTrigger>
</HelpTip>
<SelectContent>
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
@@ -201,9 +203,11 @@ export function IntakeForm({
onValueChange={onProductId}
disabled={isPreparing}
>
<SelectTrigger>
<SelectValue placeholder="Select a product…" />
</SelectTrigger>
<HelpTip label="Every cell repo mapped to this product is cloned and read by the agent">
<SelectTrigger>
<SelectValue placeholder="Select a product…" />
</SelectTrigger>
</HelpTip>
<SelectContent>
{products.map((p) => (
<SelectItem key={p.id} value={p.id}>
@@ -229,14 +233,16 @@ export function IntakeForm({
What do you want to build?{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="intake-initial-message"
value={initialMessage}
onChange={(e) => onInitialMessage(e.target.value)}
placeholder="Describe the idea. The agent will read the code and ask sharp follow-ups…"
rows={4}
disabled={isPreparing}
/>
<HelpTip label="Kicks off the interview — you can refine details in chat afterward">
<Textarea
id="intake-initial-message"
value={initialMessage}
onChange={(e) => onInitialMessage(e.target.value)}
placeholder="Describe the idea. The agent will read the code and ask sharp follow-ups…"
rows={4}
disabled={isPreparing}
/>
</HelpTip>
</div>
{/* Button's own disabled:pointer-events-none would swallow hover, so
+24 -18
View File
@@ -41,9 +41,11 @@ export function SuccessCard({
<CardContent className="pb-3 space-y-2">
<p className="text-sm font-medium">{taskTitle}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{team.replace("_", " ")}
</Badge>
<HelpTip label="Delivery cell this task was routed to">
<Badge variant="secondary" className="text-xs">
{team.replace("_", " ")}
</Badge>
</HelpTip>
<HelpTip label={`Full task ID: ${taskId}`}>
<span className="text-xs text-muted-foreground">
ID: {taskId.slice(0, 8)}
@@ -53,21 +55,25 @@ export function SuccessCard({
</CardContent>
<CardFooter className="gap-2 pt-0">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link
prefetch={false}
href={`/tasks/${taskId}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View Task
</Link>
</Button>
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start Another
</Button>
<HelpTip label="Opens the new task's detail page in a new tab">
<Button variant="outline" size="sm" asChild className="flex-1">
<Link
prefetch={false}
href={`/tasks/${taskId}`}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
View Task
</Link>
</Button>
</HelpTip>
<HelpTip label="Ends this session and resets to a fresh intake chat">
<Button size="sm" className="flex-1" onClick={onStartAnother}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Start Another
</Button>
</HelpTip>
</CardFooter>
</Card>
);
@@ -10,6 +10,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, X, GripVertical } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface AcceptanceCriteriaEditorProps {
criteria: string[];
@@ -53,12 +54,16 @@ export function AcceptanceCriteriaEditor({
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>
Acceptance Criteria <span className="text-destructive">*</span>
</Label>
<span className="text-xs text-muted-foreground">
{criteria.length} item{criteria.length !== 1 ? "s" : ""}
</span>
<HelpTip label="QA and reviewers check each of these directly against the shipped work to decide pass/fail.">
<Label>
Acceptance Criteria <span className="text-destructive">*</span>
</Label>
</HelpTip>
<HelpTip label="How many criteria are defined so far — at least one is required to submit.">
<span className="text-xs text-muted-foreground">
{criteria.length} item{criteria.length !== 1 ? "s" : ""}
</span>
</HelpTip>
</div>
{/* Existing criteria list */}
@@ -70,11 +75,13 @@ export function AcceptanceCriteriaEditor({
<span className="text-sm text-muted-foreground w-6">
{index + 1}.
</span>
<Input
value={criterion}
onChange={(e) => handleUpdate(index, e.target.value)}
className="flex-1 h-8"
/>
<HelpTip label="Edit this criterion's text directly — changes save as you type.">
<Input
value={criterion}
onChange={(e) => handleUpdate(index, e.target.value)}
className="flex-1 h-8"
/>
</HelpTip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -96,22 +103,31 @@ export function AcceptanceCriteriaEditor({
{/* Add new criterion */}
<div className="flex items-center gap-2">
<Input
value={newCriterion}
onChange={(e) => setNewCriterion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter acceptance criterion and press Enter..."
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleAdd}
disabled={!newCriterion.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="A specific, testable condition — Enter or Add appends it to the list above.">
<Input
value={newCriterion}
onChange={(e) => setNewCriterion(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter acceptance criterion and press Enter..."
className="flex-1"
/>
</HelpTip>
<HelpTip label="Appends the text on the left as a new criterion; disabled until you type something.">
<span
className="inline-block"
tabIndex={!newCriterion.trim() ? 0 : undefined}
>
<Button
type="button"
variant="outline"
onClick={handleAdd}
disabled={!newCriterion.trim()}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</span>
</HelpTip>
</div>
{/* Helper text */}
@@ -74,14 +74,16 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
return (
<>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setOpen(true)}
>
<Rocket className="h-4 w-4 mr-1" />
Approve &amp; Start
</Button>
<HelpTip label="Hands this task to the Main PM and unblocks any BACKLOG child tasks (CEO-only).">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setOpen(true)}
>
<Rocket className="h-4 w-4 mr-1" />
Approve &amp; Start
</Button>
</HelpTip>
<Dialog
open={open}
@@ -97,7 +99,9 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
</DialogHeader>
<div className="space-y-2">
<Label>Board review (Product Owner &amp; Head of Marketing)</Label>
<HelpTip label="Fetched live only while this dialog is open — read it before approving.">
<Label>Board review (Product Owner &amp; Head of Marketing)</Label>
</HelpTip>
{boardLoading ? (
<p className="text-xs text-muted-foreground">
Loading board review
@@ -140,18 +144,27 @@ export function ApproveAndStartButton({ task }: ApproveAndStartButtonProps) {
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={approveAndStartMutation.isPending}
className="bg-green-600 hover:bg-green-700"
>
{approveAndStartMutation.isPending
? "Starting..."
: "Approve & Start"}
</Button>
<HelpTip label="Closes without approving; the note above is discarded.">
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
</HelpTip>
<HelpTip label="Submits the approval note and hands the task to the Main PM.">
<span
className="inline-block"
tabIndex={approveAndStartMutation.isPending ? 0 : undefined}
>
<Button
onClick={handleConfirm}
disabled={approveAndStartMutation.isPending}
className="bg-green-600 hover:bg-green-700"
>
{approveAndStartMutation.isPending
? "Starting..."
: "Approve & Start"}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -233,9 +233,11 @@ export function CreateTaskDialog() {
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<HelpTip label="5-200 characters. Shown in the task list, task detail, and used as the default PR title.">
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
</HelpTip>
<Input
id="title"
value={title}
@@ -262,9 +264,11 @@ export function CreateTaskDialog() {
{/* Team, Priority, Complexity, Status, Nature */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="space-y-2">
<Label>
Team <span className="text-destructive">*</span>
</Label>
<HelpTip label="Which cell owns this task — governs claim eligibility and the branch name (feature/{team}/...).">
<Label>
Team <span className="text-destructive">*</span>
</Label>
</HelpTip>
<Select value={team} onValueChange={(v) => setTeam(v as Team)}>
<SelectTrigger className="w-full">
<SelectValue />
@@ -279,7 +283,9 @@ export function CreateTaskDialog() {
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<HelpTip label="Pending is claimable immediately; Backlog waits for PM setup (dependencies/session) before it can be activated.">
<Label>Status</Label>
</HelpTip>
<Select
value={status}
onValueChange={(v) => setStatus(v as TaskStatus)}
@@ -297,7 +303,9 @@ export function CreateTaskDialog() {
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<HelpTip label="Orders claim/dispatch queues — P0 tasks surface first. Doesn't force who claims it or block lower priorities.">
<Label>Priority</Label>
</HelpTip>
<Select
value={String(priority)}
onValueChange={(v) => setPriority(parseInt(v, 10))}
@@ -315,7 +323,9 @@ export function CreateTaskDialog() {
</Select>
</div>
<div className="space-y-2">
<Label>Complexity</Label>
<HelpTip label="Medium/High auto-routes a code task to the cell or Main PM to break down; a bare High dev task with no subtasks self-blocks.">
<Label>Complexity</Label>
</HelpTip>
<Select
value={complexity}
onValueChange={(v) => setComplexity(v as Complexity)}
@@ -333,7 +343,9 @@ export function CreateTaskDialog() {
</Select>
</div>
<div className="space-y-2">
<Label>Nature</Label>
<HelpTip label="Filtering only: Non-Technical root tasks awaiting PM review surface in the Board's strategic queue.">
<Label>Nature</Label>
</HelpTip>
<Select
value={nature}
onValueChange={(v) => setNature(v as TaskNature)}
@@ -373,7 +385,9 @@ export function CreateTaskDialog() {
type="button"
className="w-full justify-between"
>
<span>Advanced Options</span>
<HelpTip label="Parent task, agent assignment, task type, and repo/product routing.">
<span>Advanced Options</span>
</HelpTip>
{advancedOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
@@ -384,7 +398,9 @@ export function CreateTaskDialog() {
<CollapsibleContent className="space-y-4 pt-4">
{/* Parent Task */}
<div className="space-y-2">
<Label>Parent Task (for subtasks)</Label>
<HelpTip label="Nests this task under another — its claim order is then gated by sequence relative to siblings under the same parent.">
<Label>Parent Task (for subtasks)</Label>
</HelpTip>
<TaskSelector
value={parentTaskId}
onChange={setParentTaskId}
@@ -398,7 +414,9 @@ export function CreateTaskDialog() {
{/* Assign To */}
<div className="space-y-2">
<Label>Assign To</Label>
<HelpTip label="Pin a specific agent; leave unassigned to let the orchestrator route by role, team, and availability.">
<Label>Assign To</Label>
</HelpTip>
<AgentSelector
value={assignedTo}
onChange={setAssignedTo}
@@ -415,9 +433,11 @@ export function CreateTaskDialog() {
<div className="space-y-4 pt-4 border-t">
<div className="flex items-center gap-2 mb-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm">
Git & Work Configuration
</span>
<HelpTip label="Every task type follows the same branch → commits → PR workflow; these fields just route it.">
<span className="font-medium text-sm">
Git & Work Configuration
</span>
</HelpTip>
</div>
{/* Task Type */}
@@ -447,7 +467,9 @@ export function CreateTaskDialog() {
{/* Project — required UNLESS a Product is picked (fan-out task) */}
<div className="space-y-2">
<Label>Project</Label>
<HelpTip label="The repo the branch/PR are opened against once this task is claimed.">
<Label>Project</Label>
</HelpTip>
<ProjectSelector
value={projectId || null}
onChange={(value) => setProjectId(value || "")}
@@ -467,7 +489,9 @@ export function CreateTaskDialog() {
{/* Product (optional) — drives per-cell project routing of subtasks */}
<div className="space-y-2">
<Label>Product</Label>
<HelpTip label="Fan-out: each delegated subtask routes to that cell's own mapped project instead of one repo.">
<Label>Product</Label>
</HelpTip>
<Select
value={productId || "none"}
onValueChange={(v) => setProductId(v === "none" ? "" : v)}
@@ -497,19 +521,28 @@ export function CreateTaskDialog() {
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
resetForm();
}}
>
Cancel
</Button>
<Button type="submit" disabled={createTask.isPending}>
{createTask.isPending ? "Creating..." : "Create Task"}
</Button>
<HelpTip label="Discards everything entered above without creating a task.">
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
resetForm();
}}
>
Cancel
</Button>
</HelpTip>
<HelpTip label="Validates the required fields, then creates the task via the API.">
<span
className="inline-block"
tabIndex={createTask.isPending ? 0 : undefined}
>
<Button type="submit" disabled={createTask.isPending}>
{createTask.isPending ? "Creating..." : "Create Task"}
</Button>
</span>
</HelpTip>
</div>
</form>
</DialogContent>
@@ -20,6 +20,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Search, X, Link2 } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface DependencySelectorProps {
selectedIds: string[];
@@ -68,7 +69,9 @@ export function DependencySelector({
return (
<div className="space-y-2">
<Label>Dependencies (optional)</Label>
<HelpTip label="This task cannot be claimed until every dependency listed here reaches a terminal state.">
<Label>Dependencies (optional)</Label>
</HelpTip>
{/* Selected dependencies */}
{selectedTasks.length > 0 && (
@@ -81,9 +84,14 @@ export function DependencySelector({
<div className="flex items-center gap-2 min-w-0">
<Link2 className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm truncate">{task.title}</span>
<Badge variant="outline" className="font-mono text-xs shrink-0">
{task.id.slice(0, 8)}
</Badge>
<HelpTip label="First 8 characters of this task's full ID.">
<Badge
variant="outline"
className="font-mono text-xs shrink-0"
>
{task.id.slice(0, 8)}
</Badge>
</HelpTip>
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -106,25 +114,29 @@ export function DependencySelector({
{/* Add dependency popover */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-full justify-start"
>
<Search className="h-4 w-4 mr-2" />
Search tasks to add as dependencies...
</Button>
</PopoverTrigger>
<HelpTip label="Only shows tasks that aren't already completed or cancelled — those can't block anything.">
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-full justify-start"
>
<Search className="h-4 w-4 mr-2" />
Search tasks to add as dependencies...
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-80 sm:w-96 p-0" align="start">
<div className="p-2 border-b">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title or ID..."
className="h-8"
autoFocus
/>
<HelpTip label="Matches against task title or ID as you type.">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by title or ID..."
className="h-8"
autoFocus
/>
</HelpTip>
</div>
<ScrollArea className="h-[240px]">
{filteredTasks.length === 0 ? (
@@ -136,36 +148,44 @@ export function DependencySelector({
{filteredTasks.map((task) => {
const isSelected = selectedIds.includes(task.id);
return (
<Button
<HelpTip
key={task.id}
type="button"
variant="ghost"
className={`w-full h-auto justify-start gap-2 p-2 font-normal whitespace-normal ${
isSelected ? "bg-primary/10 hover:bg-primary/10" : ""
}`}
onClick={() => toggleTask(task.id)}
label={
isSelected
? "Click to remove this dependency"
: "Click to add this dependency"
}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none shrink-0"
aria-hidden
/>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm truncate">{task.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="font-mono text-xs"
>
{task.id.slice(0, 8)}
</Badge>
<span className="capitalize">
{task.status.replace(/_/g, " ")}
</span>
<Button
type="button"
variant="ghost"
className={`w-full h-auto justify-start gap-2 p-2 font-normal whitespace-normal ${
isSelected ? "bg-primary/10 hover:bg-primary/10" : ""
}`}
onClick={() => toggleTask(task.id)}
>
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none shrink-0"
aria-hidden
/>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm truncate">{task.title}</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge
variant="outline"
className="font-mono text-xs"
>
{task.id.slice(0, 8)}
</Badge>
<span className="capitalize">
{task.status.replace(/_/g, " ")}
</span>
</div>
</div>
</div>
</Button>
</Button>
</HelpTip>
);
})}
</div>
@@ -81,34 +81,50 @@ export function DocsStatusBadge({
// Full variant with labels
return (
<div className={cn("flex flex-col gap-2", className)}>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<FileCheck className="h-4 w-4" />
Documentation
</span>
<span className="flex items-center gap-1">
{docsComplete ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-amber-600" />
)}
{docsComplete ? "Complete" : "Pending"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<GitPullRequest className="h-4 w-4" />
Pull Request
</span>
<span className="flex items-center gap-1">
{prCreated ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-amber-600" />
)}
{prCreated ? "Created" : "Pending"}
</span>
</div>
<HelpTip
label={
docsComplete
? DOCS_DESCRIPTIONS.complete
: "Blocks the awaiting_documentation → awaiting_pm_review handoff until the documenter marks it done."
}
>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<FileCheck className="h-4 w-4" />
Documentation
</span>
<span className="flex items-center gap-1">
{docsComplete ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-amber-600" />
)}
{docsComplete ? "Complete" : "Pending"}
</span>
</div>
</HelpTip>
<HelpTip
label={
prCreated
? PR_DESCRIPTIONS.created
: "The PR is opened before QA review, not after — this task hasn't reached that step yet."
}
>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<GitPullRequest className="h-4 w-4" />
Pull Request
</span>
<span className="flex items-center gap-1">
{prCreated ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-amber-600" />
)}
{prCreated ? "Created" : "Pending"}
</span>
</div>
</HelpTip>
</div>
);
}
+46 -19
View File
@@ -166,7 +166,9 @@ function EditTaskDialogInner({
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="edit-title">Title</Label>
<HelpTip label="5-200 characters. Shown in the task list, task detail, and used as the default PR title.">
<Label htmlFor="edit-title">Title</Label>
</HelpTip>
<Input
id="edit-title"
value={title}
@@ -187,7 +189,9 @@ function EditTaskDialogInner({
{/* Team, Priority, Complexity, Nature */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label>Team</Label>
<HelpTip label="Which cell owns this task — governs claim eligibility. Changing it after a branch exists doesn't rename the branch.">
<Label>Team</Label>
</HelpTip>
<Select value={team} onValueChange={(v) => setTeam(v as Team)}>
<SelectTrigger>
<SelectValue />
@@ -202,7 +206,9 @@ function EditTaskDialogInner({
</Select>
</div>
<div className="space-y-2">
<Label>Priority</Label>
<HelpTip label="Orders claim/dispatch queues — P0 tasks surface first. Doesn't force who claims it or block lower priorities.">
<Label>Priority</Label>
</HelpTip>
<Select
value={String(priority)}
onValueChange={(v) => setPriority(parseInt(v, 10))}
@@ -220,7 +226,9 @@ function EditTaskDialogInner({
</Select>
</div>
<div className="space-y-2">
<Label>Complexity</Label>
<HelpTip label="Medium/High auto-routes a code task to the cell or Main PM to break down; a bare High dev task with no subtasks self-blocks.">
<Label>Complexity</Label>
</HelpTip>
<Select
value={complexity}
onValueChange={(v) => setComplexity(v as Complexity)}
@@ -238,7 +246,9 @@ function EditTaskDialogInner({
</Select>
</div>
<div className="space-y-2">
<Label>Nature</Label>
<HelpTip label="Filtering only: Non-Technical root tasks awaiting PM review surface in the Board's strategic queue.">
<Label>Nature</Label>
</HelpTip>
<Select
value={nature}
onValueChange={(v) => setNature(v as TaskNature)}
@@ -265,7 +275,9 @@ function EditTaskDialogInner({
type="button"
className="w-full justify-between"
>
<span>Advanced Options</span>
<HelpTip label="Agent assignment, target date, task type, and repo routing.">
<span>Advanced Options</span>
</HelpTip>
{advancedOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
@@ -276,7 +288,9 @@ function EditTaskDialogInner({
<CollapsibleContent className="space-y-4 pt-4">
{/* Assigned To */}
<div className="space-y-2">
<Label>Assigned To</Label>
<HelpTip label="Pin a specific agent; leave unassigned to let the orchestrator route by role, team, and availability.">
<Label>Assigned To</Label>
</HelpTip>
<AgentSelector
value={assignedTo}
onChange={setAssignedTo}
@@ -287,7 +301,9 @@ function EditTaskDialogInner({
{/* Target Date */}
<div className="space-y-2">
<Label>Target Date</Label>
<HelpTip label="Informational deadline only — nothing in the lifecycle enforces or auto-escalates on it.">
<Label>Target Date</Label>
</HelpTip>
<Input
type="datetime-local"
value={targetDate}
@@ -328,7 +344,9 @@ function EditTaskDialogInner({
{/* Project Selector (required - all tasks follow git workflow) */}
<div className="space-y-2">
<Label>Project</Label>
<HelpTip label="The repo the branch/PR are opened against. Locked once a branch exists — see below.">
<Label>Project</Label>
</HelpTip>
<ProjectSelector
value={projectId || null}
onChange={(value) => setProjectId(value || "")}
@@ -347,16 +365,25 @@ function EditTaskDialogInner({
{/* Actions */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={updateTask.isPending}>
{updateTask.isPending ? "Saving..." : "Save Changes"}
</Button>
<HelpTip label="Discards any edits made above and closes without saving.">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
</HelpTip>
<HelpTip label="Sends the changed fields to the API and updates the task in place.">
<span
className="inline-block"
tabIndex={updateTask.isPending ? 0 : undefined}
>
<Button type="submit" disabled={updateTask.isPending}>
{updateTask.isPending ? "Saving..." : "Save Changes"}
</Button>
</span>
</HelpTip>
</div>
</form>
</DialogContent>
@@ -52,10 +52,12 @@ export function GitStatusBadge({
if (task.pr_number) {
return (
<MaybeLink href={task.pr_url ?? pullUrl(repoUrl, task.pr_number)}>
<Badge className="gap-1 text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400">
<GitPullRequest className="h-3 w-3" />
PR #{task.pr_number}
</Badge>
<HelpTip label="Opens this task's pull request on GitHub in a new tab.">
<Badge className="gap-1 text-xs bg-purple-500/10 text-purple-600 dark:text-purple-400">
<GitPullRequest className="h-3 w-3" />
PR #{task.pr_number}
</Badge>
</HelpTip>
</MaybeLink>
);
}
+17 -7
View File
@@ -41,12 +41,20 @@ export function MarkdownEditor({
>
<TabsList className="h-8">
<TabsTrigger value="write" className="text-xs px-2 h-6">
<Edit3 className="h-3 w-3 mr-1" />
Write
<HelpTip label="Edit the raw markdown text.">
<span className="flex items-center">
<Edit3 className="h-3 w-3 mr-1" />
Write
</span>
</HelpTip>
</TabsTrigger>
<TabsTrigger value="preview" className="text-xs px-2 h-6">
<Eye className="h-3 w-3 mr-1" />
Preview
<HelpTip label="Renders the markdown as it will appear elsewhere in the app.">
<span className="flex items-center">
<Eye className="h-3 w-3 mr-1" />
Preview
</span>
</HelpTip>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -76,9 +84,11 @@ export function MarkdownEditor({
<span>Markdown supported</span>
</HelpTip>
{minLength && (
<span className={value.length < minLength ? "text-destructive" : ""}>
{value.length}/{minLength} min characters
</span>
<HelpTip label="Submission is blocked until the count reaches this minimum.">
<span className={value.length < minLength ? "text-destructive" : ""}>
{value.length}/{minLength} min characters
</span>
</HelpTip>
)}
</div>
+95 -64
View File
@@ -41,6 +41,7 @@ import {
import { toast } from "sonner";
import { EditTaskDialog } from "./edit-task-dialog";
import { RequiredNotesDialog } from "./task-detail/task-action-dialogs";
import { HelpTip } from "@/components/ui/help-tip";
interface TaskActionsProps {
task: Task;
@@ -169,59 +170,77 @@ export function TaskActions({
<DropdownMenuContent align="end">
{/* Edit option */}
{showEdit && canEdit && (
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<HelpTip label="Opens the edit dialog to change title, description, team, priority, and more.">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
</HelpTip>
)}
{/* Backlog status info */}
{isBacklog && (
<>
<DropdownMenuItem disabled className="text-muted-foreground">
<Clock className="h-4 w-4 mr-2" />
Awaiting PM Activation
</DropdownMenuItem>
<HelpTip label="Backlog tasks need PM setup (dependencies/session) before they become claimable.">
<span className="block" tabIndex={0}>
<DropdownMenuItem disabled className="text-muted-foreground">
<Clock className="h-4 w-4 mr-2" />
Awaiting PM Activation
</DropdownMenuItem>
</span>
</HelpTip>
<DropdownMenuSeparator />
</>
)}
{/* Lifecycle actions */}
{task.status === TaskStatus.PENDING && (
<DropdownMenuItem onClick={() => handleAction("claim")}>
<Play className="h-4 w-4 mr-2" />
Claim
</DropdownMenuItem>
<HelpTip label="Locks this task to you (pending → claimed).">
<DropdownMenuItem onClick={() => handleAction("claim")}>
<Play className="h-4 w-4 mr-2" />
Claim
</DropdownMenuItem>
</HelpTip>
)}
{task.status === TaskStatus.CLAIMED && (
<DropdownMenuItem onClick={() => handleAction("start")}>
<Play className="h-4 w-4 mr-2" />
Start
</DropdownMenuItem>
<HelpTip label="Begins active work (claimed → in_progress).">
<DropdownMenuItem onClick={() => handleAction("start")}>
<Play className="h-4 w-4 mr-2" />
Start
</DropdownMenuItem>
</HelpTip>
)}
{task.status === TaskStatus.IN_PROGRESS && (
<DropdownMenuItem onClick={() => handleAction("pause")}>
<Pause className="h-4 w-4 mr-2" />
Pause
</DropdownMenuItem>
<HelpTip label="Stops work temporarily; can be resumed later (in_progress → paused).">
<DropdownMenuItem onClick={() => handleAction("pause")}>
<Pause className="h-4 w-4 mr-2" />
Pause
</DropdownMenuItem>
</HelpTip>
)}
{task.status === TaskStatus.PAUSED && (
<DropdownMenuItem onClick={() => handleAction("resume")}>
<Play className="h-4 w-4 mr-2" />
Resume
</DropdownMenuItem>
<HelpTip label="Resumes paused work (paused → in_progress).">
<DropdownMenuItem onClick={() => handleAction("resume")}>
<Play className="h-4 w-4 mr-2" />
Resume
</DropdownMenuItem>
</HelpTip>
)}
{task.status === TaskStatus.BLOCKED && (
<DropdownMenuItem onClick={() => handleAction("unblock")}>
<Play className="h-4 w-4 mr-2" />
Unblock
</DropdownMenuItem>
<HelpTip label="Clears the block and restores the original owner (blocked → in_progress or pending).">
<DropdownMenuItem onClick={() => handleAction("unblock")}>
<Play className="h-4 w-4 mr-2" />
Unblock
</DropdownMenuItem>
</HelpTip>
)}
{task.status === TaskStatus.CANCELLED && (
<DropdownMenuItem onClick={() => handleAction("reopen")}>
<Play className="h-4 w-4 mr-2" />
Reopen
</DropdownMenuItem>
<HelpTip label="Resurrects a cancelled task back to pending — an audited privileged override.">
<DropdownMenuItem onClick={() => handleAction("reopen")}>
<Play className="h-4 w-4 mr-2" />
Reopen
</DropdownMenuItem>
</HelpTip>
)}
{task.status !== TaskStatus.COMPLETED &&
@@ -230,42 +249,50 @@ export function TaskActions({
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleAction("complete")}>
<CheckCircle className="h-4 w-4 mr-2" />
Complete
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</DropdownMenuItem>
<HelpTip label="Directly completes the task — requires the normal review chain done, or this is your own PM root.">
<DropdownMenuItem onClick={() => handleAction("complete")}>
<CheckCircle className="h-4 w-4 mr-2" />
Complete
</DropdownMenuItem>
</HelpTip>
<HelpTip label="Cancels this task and every subtask beneath it; the reason becomes the audit record.">
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</DropdownMenuItem>
</HelpTip>
</>
)}
{/* Cancel for backlog tasks (allowed - can remove from backlog) */}
{isBacklog && (
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</DropdownMenuItem>
<HelpTip label="Removes this task from the backlog and cancels any subtasks; the reason becomes the audit record.">
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel
</DropdownMenuItem>
</HelpTip>
)}
{/* Delete option */}
{showDelete && canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
<HelpTip label="Permanently deletes this task and every subtask beneath it — cannot be undone.">
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</HelpTip>
</>
)}
</DropdownMenuContent>
@@ -314,13 +341,17 @@ export function TaskActions({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteTask.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
<HelpTip label="Closes without deleting anything.">
<AlertDialogCancel>Cancel</AlertDialogCancel>
</HelpTip>
<HelpTip label="Deletes the task and cascades to every subtask beneath it, leaf-first.">
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteTask.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</HelpTip>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -44,11 +44,15 @@ describe("TabProgress progress-update collapse", () => {
const task = buildTask({ progress_updates: makeUpdates(30) });
const { container } = render(<TabProgress task={task} />);
// Each update's collapse trigger is the only <button> in its <li> that
// carries Radix's data-state attribute; list order matches sort order
// (newest first), so the first two entries are the 2 most recent.
// Scope to the collapse trigger specifically via its test id — the row
// also carries a delete button wrapped in a HelpTip/Tooltip, which
// stamps its own Radix data-state, so a bare "button[data-state]"
// selector would match both. List order matches sort order (newest
// first), so the first two entries are the 2 most recent.
const triggers = Array.from(
container.querySelectorAll("li button[data-state]"),
container.querySelectorAll(
'li button[data-testid="progress-update-trigger"]',
),
);
expect(triggers).toHaveLength(30);
expect(triggers[0]).toHaveAttribute("data-state", "open");
@@ -54,8 +54,14 @@ describe("Task detail readability: long progress history + long criteria list",
const task = buildTask({ progress_updates: makeUpdates(32) });
const { container } = render(<TabProgress task={task} />);
// Scope to the collapse trigger specifically via its test id — the row
// also carries a delete button wrapped in a HelpTip/Tooltip, which
// stamps its own Radix data-state, so a bare "button[data-state]"
// selector would match both.
const triggers = Array.from(
container.querySelectorAll("li button[data-state]"),
container.querySelectorAll(
'li button[data-testid="progress-update-trigger"]',
),
);
expect(triggers).toHaveLength(32);
const openCount = triggers.filter(
@@ -180,19 +180,27 @@ export function AcceptanceCriteria({ task }: AcceptanceCriteriaProps) {
return (
<CollapsibleSection
title="Acceptance Criteria"
title={
<HelpTip label="What proves this task is done — reviewers and the auto-submit gate check coverage against these">
<span className="w-fit">Acceptance Criteria</span>
</HelpTip>
}
content={criteriaText}
open={isAdding || editingIndex !== null ? true : undefined}
actions={
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{completedCount}/{criteria.length} completed
</span>
<HelpTip label="Criteria whose checkbox is checked, out of the total">
<span className="text-sm text-muted-foreground w-fit">
{completedCount}/{criteria.length} completed
</span>
</HelpTip>
{!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Adds a new empty criterion to this list">
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)}
</div>
}
@@ -5,6 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Bookmark, Clock, User, ListTodo, FileText } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface CheckpointCardProps {
checkpoint: Checkpoint;
@@ -14,10 +15,12 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
return (
<Card className="overflow-hidden">
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bookmark className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">Checkpoint</span>
</div>
<HelpTip label="A saved state snapshot an agent leaves so work can resume later without re-deriving context">
<div className="flex items-center gap-2">
<Bookmark className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">Checkpoint</span>
</div>
</HelpTip>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatAbsoluteTimestamp(checkpoint.timestamp)}
@@ -32,7 +35,9 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
{/* State Summary */}
<div className="mb-4">
<h4 className="text-sm font-medium mb-1">State Summary</h4>
<HelpTip label="What the agent understood to be true about the task when this checkpoint was saved">
<h4 className="text-sm font-medium mb-1 w-fit">State Summary</h4>
</HelpTip>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.state_summary}
</p>
@@ -41,10 +46,12 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
{/* Remaining Work */}
{checkpoint.remaining_work.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Remaining Work</h4>
</div>
<HelpTip label="Sub-steps the agent identified as still outstanding at checkpoint time">
<div className="flex items-center gap-2 mb-2 w-fit">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Remaining Work</h4>
</div>
</HelpTip>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{checkpoint.remaining_work.map((item, idx) => (
<li key={idx}>{item}</li>
@@ -56,10 +63,12 @@ export function CheckpointCard({ checkpoint }: CheckpointCardProps) {
{/* Notes */}
{checkpoint.notes && (
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Notes</h4>
</div>
<HelpTip label="Free-form notes the checkpointing agent left for whoever resumes this task">
<div className="flex items-center gap-2 mb-2 w-fit">
<FileText className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-medium">Notes</h4>
</div>
</HelpTip>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{checkpoint.notes}
</p>
@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
import { MessageSquare, Clock } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface ProgressTimelineProps {
updates: ProgressUpdate[];
@@ -45,17 +46,21 @@ export function ProgressTimeline({ updates }: ProgressTimelineProps) {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Progress Updates</CardTitle>
<HelpTip label="Timestamped status updates posted while agents work this task, newest first">
<CardTitle className="text-lg w-fit">Progress Updates</CardTitle>
</HelpTip>
<span className="text-sm text-muted-foreground">
{updates.length} update{updates.length !== 1 ? "s" : ""}
</span>
</div>
{latestWithPercentage && (
<div className="mt-2">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-medium">{currentProgress}%</span>
</div>
<HelpTip label="The most recent percentage reported — self-reported or derived from completed plan steps">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-medium">{currentProgress}%</span>
</div>
</HelpTip>
<Progress value={currentProgress} className="h-2" />
</div>
)}
@@ -100,9 +105,11 @@ export function ProgressTimeline({ updates }: ProgressTimelineProps) {
value={update.percentage}
className="h-1.5 flex-1"
/>
<span className="text-xs text-muted-foreground w-10 text-right">
{update.percentage}%
</span>
<HelpTip label="This update's reported completion percentage, out of 100">
<span className="text-xs text-muted-foreground w-10 text-right">
{update.percentage}%
</span>
</HelpTip>
</div>
</div>
)}
@@ -65,30 +65,34 @@ export function SubtasksList({ task }: SubtasksListProps) {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<ListTree className="h-5 w-5" />
Subtasks
{totalCount > 0 && (
<Badge variant="secondary" className="ml-2">
{completedCount}/{totalCount}
</Badge>
)}
</CardTitle>
<HelpTip label="Direct children of this task; badge is completed/total">
<CardTitle className="text-lg flex items-center gap-2 w-fit">
<ListTree className="h-5 w-5" />
Subtasks
{totalCount > 0 && (
<Badge variant="secondary" className="ml-2">
{completedCount}/{totalCount}
</Badge>
)}
</CardTitle>
</HelpTip>
<div className="flex items-center gap-2">
{totalCount > 0 && (
<span className="text-sm text-muted-foreground">
{completionPercent}% complete
</span>
)}
<Link
href={`/tasks?parent=${task.id}&team=${task.team}`}
prefetch={false}
>
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</Link>
<HelpTip label="Opens the Tasks list, pre-filtered to this parent and team, to create a new subtask">
<Link
href={`/tasks?parent=${task.id}&team=${task.team}`}
prefetch={false}
>
<Button size="sm" variant="ghost">
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</Link>
</HelpTip>
</div>
</div>
</CardHeader>
@@ -77,7 +77,9 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
</code>
)}
{sib.pr_number != null && (
<Badge variant="outline">#{sib.pr_number}</Badge>
<HelpTip label="This sibling's pull request number, if one has been opened yet">
<Badge variant="outline">#{sib.pr_number}</Badge>
</HelpTip>
)}
{sib.adds_migration && (
<HelpTip label={MIGRATION_TIP}>
@@ -130,9 +132,11 @@ function SiblingCard({ sib }: { sib: CollisionSibling }) {
{sib.undeclared.length > 0 && (
<div className="rounded border border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40 p-2 text-xs">
<span className="text-amber-700 dark:text-amber-300 font-medium">
Drift touched but not declared:
</span>
<HelpTip label="Files this sibling actually touched in commits but never declared in its collision surface">
<span className="text-amber-700 dark:text-amber-300 font-medium w-fit">
Drift touched but not declared:
</span>
</HelpTip>
<div className="mt-1 flex flex-wrap gap-1">
{sib.undeclared.map((f) => (
<code
@@ -197,9 +201,11 @@ export function TabCollision({ task }: TabCollisionProps) {
<div className="space-y-4">
<DeclaredSurfaceCard data={data} />
<div>
<h3 className="text-sm font-semibold mb-3">
{siblings.length} colliding sibling{siblings.length > 1 ? "s" : ""}
</h3>
<HelpTip label="Siblings whose declared file surface overlaps yours, or that share a migration/shared-file chain">
<h3 className="text-sm font-semibold mb-3 w-fit">
{siblings.length} colliding sibling{siblings.length > 1 ? "s" : ""}
</h3>
</HelpTip>
<div className="space-y-3">
{siblings.map((sib) => (
<SiblingCard key={sib.id} sib={sib} />
@@ -218,7 +224,9 @@ function DeclaredSurfaceCard({ data }: { data: CollisionMap }) {
return (
<Card>
<CardContent className="pt-4 space-y-2">
<h3 className="text-sm font-semibold">Declared surface</h3>
<HelpTip label="The file globs, migration flag, and shared-surface flag this task pre-declared for sequencing">
<h3 className="text-sm font-semibold w-fit">Declared surface</h3>
</HelpTip>
{hasSurface ? (
<>
{data.intends_to_touch.length > 0 && (
@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { HelpTip } from "@/components/ui/help-tip";
interface TabCommitsProps {
@@ -114,13 +115,15 @@ export function TabCommits({ task }: TabCommitsProps) {
<Card>
<CardHeader className="pb-3">
<div className="grid grid-cols-3 items-center gap-4">
<CardTitle className="text-lg flex items-center gap-2">
<GitCommit className="h-5 w-5" />
Linked Commits
<span className="text-sm font-normal text-muted-foreground">
({commits.length})
</span>
</CardTitle>
<HelpTip label="Commits attributed to this task — new ones link automatically as devs push">
<CardTitle className="text-lg flex items-center gap-2 w-fit">
<GitCommit className="h-5 w-5" />
Linked Commits
<span className="text-sm font-normal text-muted-foreground">
({commits.length})
</span>
</CardTitle>
</HelpTip>
<div className="flex items-center justify-center gap-2">
{task.branch_name && (
<HelpTip label="Git branch this task is being worked on">
@@ -149,14 +152,16 @@ export function TabCommits({ task }: TabCommitsProps) {
</div>
<div className="flex justify-end">
{!isAdding && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Link
</Button>
<HelpTip label="Manually attach a commit hash — normally commits link automatically via the commit verb">
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Link
</Button>
</HelpTip>
)}
</div>
</div>
@@ -167,9 +172,11 @@ export function TabCommits({ task }: TabCommitsProps) {
<div className="border rounded-lg p-4 mb-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium mb-1 block">
Commit Hash
</label>
<HelpTip label="7-40 character git SHA; a short hash is fine">
<label className="text-sm font-medium mb-1 block w-fit">
Commit Hash
</label>
</HelpTip>
<Input
ref={hashRef}
value={newHash}
@@ -179,9 +186,11 @@ export function TabCommits({ task }: TabCommitsProps) {
/>
</div>
<div>
<label className="text-sm font-medium mb-1 block">
Commit Message
</label>
<HelpTip label="Free-text description shown wherever this commit is displayed">
<label className="text-sm font-medium mb-1 block w-fit">
Commit Message
</label>
</HelpTip>
<Input
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
@@ -224,10 +233,12 @@ export function TabCommits({ task }: TabCommitsProps) {
<p className="text-sm mt-2 mb-4">
Commits will be linked as developers push code for this task.
</p>
<Button variant="outline" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-2" />
Link Commit Manually
</Button>
<HelpTip label="Manually attach a commit hash if auto-linking missed one">
<Button variant="outline" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-2" />
Link Commit Manually
</Button>
</HelpTip>
</div>
) : (
<div className="space-y-3">
@@ -266,10 +277,12 @@ export function TabCommits({ task }: TabCommitsProps) {
)}
{/* Time */}
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(commit.timestamp)}
</span>
<HelpTip label={formatAbsoluteTimestamp(commit.timestamp)}>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(commit.timestamp)}
</span>
</HelpTip>
</div>
</div>
</div>
@@ -26,6 +26,17 @@ interface TabDependenciesProps {
task: Task;
}
// Grounded in roboco/services/gateway/claim_guards.py: dependency_ids
// genuinely gates claim (a task can't be claimed until every dependency
// reaches completed/cancelled); blocker_ids is the pure-informational
// inverse ("tasks this one is blocking") and does not itself gate anything.
const FIELD_TIPS: Record<DependencyListProps["field"], string> = {
dependency_ids:
"Tasks that must reach completed/cancelled before this one can be claimed.",
blocker_ids:
"Tasks that depend on this one — informational; does not block this task's own claim.",
};
interface DependencyListProps {
task: Task;
field: "dependency_ids" | "blocker_ids";
@@ -124,19 +135,33 @@ function DependencyList({
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
{icon}
{title}
<HelpTip label={FIELD_TIPS[field]}>
<span className="flex items-center gap-2">
{icon}
{title}
</span>
</HelpTip>
{ids.length > 0 && field === "blocker_ids" && (
<Badge variant="destructive" className="ml-2">
{ids.length}
</Badge>
<HelpTip label="Number of other tasks currently depending on this one">
<Badge variant="destructive" className="ml-2">
{ids.length}
</Badge>
</HelpTip>
)}
</CardTitle>
{!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip
label={
field === "dependency_ids"
? "Add a task ID this task must wait on"
: "Add a task ID that this task is blocking"
}
>
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)}
</div>
</CardHeader>
@@ -167,9 +192,17 @@ function DependencyList({
</span>
</Link>
</HelpTip>
<Badge variant="outline" className={badgeClass}>
{badgeLabel}
</Badge>
<HelpTip
label={
field === "dependency_ids"
? "Click the id to open this dependency task"
: "This task is blocking the linked task's claim"
}
>
<Badge variant="outline" className={badgeClass}>
{badgeLabel}
</Badge>
</HelpTip>
<HelpTip label="Remove this dependency">
<Button
size="sm"
@@ -198,25 +231,35 @@ function DependencyList({
className="h-8 text-sm flex-1 font-mono"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewId("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewId("");
setIsAdding(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip
label={
field === "dependency_ids"
? "Add this task ID as a dependency"
: "Add this task ID as a blocked task"
}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!newId.trim() || updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!newId.trim() || updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</li>
)}
</ul>
@@ -367,8 +410,12 @@ export function TabDependencies({ task }: TabDependenciesProps) {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<ArrowUp className="h-5 w-5 text-purple-500" />
Parent Task
<HelpTip label="The coordination task this one was delegated under, if any">
<span className="flex items-center gap-2">
<ArrowUp className="h-5 w-5 text-purple-500" />
Parent Task
</span>
</HelpTip>
</CardTitle>
</div>
</CardHeader>
@@ -386,26 +433,30 @@ export function TabDependencies({ task }: TabDependenciesProps) {
className="h-8 text-sm flex-1 font-mono"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
setParentValue(task.parent_task_id ?? "");
setEditingParent(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleParentSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setParentValue(task.parent_task_id ?? "");
setEditingParent(false);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save this parent task ID">
<Button
size="sm"
onClick={handleParentSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
) : task.parent_task_id ? (
<div
@@ -427,17 +478,19 @@ export function TabDependencies({ task }: TabDependenciesProps) {
<Badge variant="outline" className="ml-auto">
View Parent
</Badge>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditingParent();
}}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
<HelpTip label="Opens editing to change or clear the parent task — does not delete anything">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditingParent();
}}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</HelpTip>
</div>
) : (
<p
@@ -455,8 +508,12 @@ export function TabDependencies({ task }: TabDependenciesProps) {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Hash className="h-5 w-5 text-amber-500" />
Sequence
<HelpTip label="A same-parent sibling with a strictly lower sequence must go terminal before this one can be claimed; ties run in parallel">
<span className="flex items-center gap-2">
<Hash className="h-5 w-5 text-amber-500" />
Sequence
</span>
</HelpTip>
</CardTitle>
</div>
</CardHeader>
@@ -475,23 +532,27 @@ export function TabDependencies({ task }: TabDependenciesProps) {
className="h-8 text-sm flex-1"
disabled={updateTask.isPending}
/>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingSequence(false)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleSequenceSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => setEditingSequence(false)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save this sequence number">
<Button
size="sm"
onClick={handleSequenceSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
</HelpTip>
</div>
) : (
<div
@@ -504,17 +565,19 @@ export function TabDependencies({ task }: TabDependenciesProps) {
<span className="ml-2 text-xs text-muted-foreground">
Order within siblings lower runs first
</span>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditingSequence();
}}
className="ml-auto h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil className="h-3 w-3" />
</Button>
<HelpTip label="Edit the sequence number">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
startEditingSequence();
}}
className="ml-auto h-7 w-7 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil className="h-3 w-3" />
</Button>
</HelpTip>
</div>
)}
</CardContent>
@@ -119,7 +119,11 @@ function FindingCard({
</div>
{finding.evidence && (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">Evidence</summary>
<HelpTip label="The raw evidence text the reviewer captured for this finding">
<summary className="cursor-pointer select-none w-fit">
Evidence
</summary>
</HelpTip>
<pre className="mt-1 whitespace-pre-wrap rounded bg-muted/50 p-2">
{finding.evidence}
</pre>
@@ -174,20 +178,31 @@ export function TabFindings({ task }: TabFindingsProps) {
{(data?.summary.length ?? 0) > 0 && (
<div className="flex flex-wrap gap-2">
{data!.summary.map((s) => (
<Badge key={s.origin} variant="outline">
{ORIGIN_LABEL[s.origin] ?? s.origin}: {s.open} open ·{" "}
{s.addressed + s.verified + s.waived} closed
</Badge>
<HelpTip
key={s.origin}
label="Closed combines addressed, verified, and waived findings"
>
<Badge variant="outline">
{ORIGIN_LABEL[s.origin] ?? s.origin}: {s.open} open ·{" "}
{s.addressed + s.verified + s.waived} closed
</Badge>
</HelpTip>
))}
</div>
)}
{rounds.map((group) => (
<div key={group.round} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Round {group.round}</h3>
<Badge variant="outline">
{ORIGIN_LABEL[group.origin] ?? group.origin}
</Badge>
<HelpTip label="Each bounce back to the assignee starts a new round">
<h3 className="text-sm font-semibold w-fit">
Round {group.round}
</h3>
</HelpTip>
<HelpTip label="Which reviewer stage raised this round's findings">
<Badge variant="outline">
{ORIGIN_LABEL[group.origin] ?? group.origin}
</Badge>
</HelpTip>
</div>
<div className="space-y-3">
{group.items.map((f) => (
@@ -62,7 +62,17 @@ function prReviewBadge(task: Task): React.ReactNode {
failed: { label: "Failed", cls: "bg-red-500" },
};
const v = map[verdict] ?? { label: verdict, cls: "bg-gray-500" };
return <Badge className={`ml-2 ${v.cls} text-white`}>{v.label}</Badge>;
const tip: Record<string, string> = {
approved: "The PR reviewer approved this task's assembled PR.",
passed: "The PR reviewer passed the in-path gate — on to PM review.",
changes_requested: "The PR reviewer asked for changes before merging.",
failed: "The PR reviewer failed this task back to needs_revision.",
};
return (
<HelpTip label={tip[verdict]}>
<Badge className={`ml-2 ${v.cls} text-white`}>{v.label}</Badge>
</HelpTip>
);
}
// The card background mirrors the PR reviewer's verdict, so a FAILED review reads
@@ -269,14 +279,17 @@ function EditableNoteCard({
</TabsTrigger>
</TabsList>
</Tabs>
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
>
<X className="h-4 w-4" />
</Button>
<HelpTip label="Discard changes without saving">
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
disabled={updateTask.isPending}
aria-label="Cancel edit"
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button
size="sm"
onClick={handleSave}
@@ -362,7 +375,11 @@ export function TabNotes({ task }: TabNotesProps) {
task={task}
field="dev_notes"
title="Developer Notes"
icon={<Code className="h-5 w-5" />}
icon={
<HelpTip label="Written by the developer — approach, decisions, anything QA should know">
<Code className="h-5 w-5" />
</HelpTip>
}
bgClass="bg-muted/50"
/>
@@ -371,7 +388,11 @@ export function TabNotes({ task }: TabNotesProps) {
task={task}
field="doc_notes"
title="Documenter Notes"
icon={<BookText className="h-5 w-5" />}
icon={
<HelpTip label="Written by the documenter during the awaiting_documentation phase">
<BookText className="h-5 w-5" />
</HelpTip>
}
bgClass="bg-muted/50"
/>
@@ -382,22 +403,32 @@ export function TabNotes({ task }: TabNotesProps) {
title="QA Notes"
icon={<TestTube className="h-5 w-5" />}
badge={
<Badge
variant={
<HelpTip
label={
task.qa_verified === true
? "default"
? "QA passed this task on to documentation."
: task.qa_verified === false
? "destructive"
: "secondary"
? "QA failed this task back to needs_revision."
: "QA hasn't reviewed this task yet."
}
className="ml-2"
>
{task.qa_verified === true
? "Passed"
: task.qa_verified === false
? "Failed"
: "Pending"}
</Badge>
<Badge
variant={
task.qa_verified === true
? "default"
: task.qa_verified === false
? "destructive"
: "secondary"
}
className="ml-2"
>
{task.qa_verified === true
? "Passed"
: task.qa_verified === false
? "Failed"
: "Pending"}
</Badge>
</HelpTip>
}
bgClass={
task.qa_verified === true
@@ -54,7 +54,9 @@ export function TabOverview({ task }: TabOverviewProps) {
{task.quick_context && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Context</CardTitle>
<HelpTip label="Short notes for resuming work after an interruption — mirrored on the Notes tab">
<CardTitle className="text-lg w-fit">Quick Context</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
<Markdown className="text-sm">{task.quick_context}</Markdown>
@@ -65,7 +67,9 @@ export function TabOverview({ task }: TabOverviewProps) {
{/* Verification Status */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Verification Status</CardTitle>
<HelpTip label="Two independent checks: the developer's self-verification, then QA's separate review">
<CardTitle className="text-lg w-fit">Verification Status</CardTitle>
</HelpTip>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
@@ -73,29 +77,33 @@ export function TabOverview({ task }: TabOverviewProps) {
<span className="text-sm text-muted-foreground">
Self Verified:
</span>
<Badge variant={task.self_verified ? "default" : "secondary"}>
{task.self_verified ? "Yes" : "No"}
</Badge>
<HelpTip label="The developer confirmed they tested their own work before requesting QA review">
<Badge variant={task.self_verified ? "default" : "secondary"}>
{task.self_verified ? "Yes" : "No"}
</Badge>
</HelpTip>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
QA Verified:
</span>
<Badge
variant={
task.qa_verified === true
? "default"
<HelpTip label="Whether QA reviewed this task: Passed, Failed (back to needs_revision), or Pending (not reviewed yet)">
<Badge
variant={
task.qa_verified === true
? "default"
: task.qa_verified === false
? "destructive"
: "secondary"
}
>
{task.qa_verified === true
? "Passed"
: task.qa_verified === false
? "destructive"
: "secondary"
}
>
{task.qa_verified === true
? "Passed"
: task.qa_verified === false
? "Failed"
: "Pending"}
</Badge>
? "Failed"
: "Pending"}
</Badge>
</HelpTip>
</div>
</div>
</CardContent>
@@ -126,6 +126,11 @@ function ApproachSection({ task, plan }: { task: Task; plan: TaskPlan }) {
actions={
isEditing ? (
<>
{/* Write/Preview is a stateful TabsTrigger wrapping it in
HelpTip would clobber Radix's data-state (see task-tabs.tsx),
and the two-word label + icon already say what it does, so
it's left untipped rather than reassert-patched for a
same-line write/preview toggle. */}
<Tabs
value={editMode}
onValueChange={(v) => setEditMode(v as "write" | "preview")}
@@ -151,21 +156,25 @@ function ApproachSection({ task, plan }: { task: Task; plan: TaskPlan }) {
<X className="h-4 w-4" />
</Button>
</HelpTip>
<Button
size="sm"
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<HelpTip label="Save this approach text to the task's plan">
<Button
size="sm"
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
disabled={updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
</HelpTip>
</>
) : (
<Button size="sm" variant="ghost" onClick={startEditing}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
</Button>
<HelpTip label="Open the approach text for editing">
<Button size="sm" variant="ghost" onClick={startEditing}>
<Edit3 className="h-4 w-4 mr-1" />
Edit
</Button>
</HelpTip>
)
}
>
@@ -302,10 +311,12 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
{completedCount}/{subTasks.length} completed
</span>
{!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Add a new sub-task to this plan">
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)}
</>
}
@@ -318,11 +329,24 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
key={subtask.id}
className="flex items-center gap-3 py-2 group"
>
<Checkbox
checked={subtask.completed}
onCheckedChange={() => handleToggle(subtask.id)}
disabled={updateTask.isPending}
/>
<HelpTip
label={
subtask.completed
? "Mark this sub-task incomplete"
: "Mark this sub-task complete"
}
>
{/* Checkbox exposes Radix's own data-state=checked/unchecked
for its check-mark styling; re-assert it explicitly after
the Tooltip wrap so the Slot merge can't clobber it (same
trap as task-tabs.tsx's TabsTrigger). */}
<Checkbox
checked={subtask.completed}
data-state={subtask.completed ? "checked" : "unchecked"}
onCheckedChange={() => handleToggle(subtask.id)}
disabled={updateTask.isPending}
/>
</HelpTip>
{editingId === subtask.id ? (
<div className="flex-1 flex items-center gap-2">
<Input
@@ -339,24 +363,28 @@ function SubTasksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</div>
) : (
<>
<span
className={`flex-1 cursor-pointer hover:bg-muted/30 px-2 py-1 -mx-2 rounded ${
subtask.completed
? "line-through text-muted-foreground"
: ""
}`}
onClick={() => {
setEditingId(subtask.id);
setEditTitle(subtask.title);
}}
>
{subtask.title}
{subtask.estimated_hours && (
<Badge variant="outline" className="ml-2 text-xs">
~{subtask.estimated_hours}h
</Badge>
)}
</span>
<HelpTip label="Click to rename this sub-task">
<span
className={`flex-1 cursor-pointer hover:bg-muted/30 px-2 py-1 -mx-2 rounded ${
subtask.completed
? "line-through text-muted-foreground"
: ""
}`}
onClick={() => {
setEditingId(subtask.id);
setEditTitle(subtask.title);
}}
>
{subtask.title}
{subtask.estimated_hours && (
<HelpTip label="Estimated effort for this sub-task, in hours">
<Badge variant="outline" className="ml-2 text-xs">
~{subtask.estimated_hours}h
</Badge>
</HelpTip>
)}
</span>
</HelpTip>
<HelpTip label="Delete this sub-task">
<Button
size="sm"
@@ -509,10 +537,12 @@ function TechConsiderationsSection({
}
actions={
!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Add a new technical consideration">
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)
}
>
@@ -536,15 +566,17 @@ function TechConsiderationsSection({
</div>
) : (
<>
<span
className="flex-1 text-sm cursor-pointer hover:bg-muted/30 px-2 py-1 -mx-2 rounded"
onClick={() => {
setEditingIdx(idx);
setEditValue(item);
}}
>
{item}
</span>
<HelpTip label="Click to edit this consideration">
<span
className="flex-1 text-sm cursor-pointer hover:bg-muted/30 px-2 py-1 -mx-2 rounded"
onClick={() => {
setEditingIdx(idx);
setEditValue(item);
}}
>
{item}
</span>
</HelpTip>
<HelpTip label="Delete this consideration">
<Button
size="sm"
@@ -708,10 +740,12 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
}
actions={
!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Add a new risk with a mitigation and severity">
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)
}
>
@@ -733,16 +767,18 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
className="text-sm"
/>
<div className="flex items-center gap-2">
<Select value={editSeverity} onValueChange={setEditSeverity}>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<HelpTip label="How likely/costly this risk is — see the severity badge for what each level means">
<Select value={editSeverity} onValueChange={setEditSeverity}>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</HelpTip>
<div className="flex-1" />
<HelpTip label="Discard and cancel">
<Button
@@ -826,16 +862,18 @@ function RisksSection({ task, plan }: { task: Task; plan: TaskPlan }) {
className="text-sm"
/>
<div className="flex items-center gap-2">
<Select value={newSeverity} onValueChange={setNewSeverity}>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
<HelpTip label="How likely/costly this risk is — see the severity badge for what each level means">
<Select value={newSeverity} onValueChange={setNewSeverity}>
<SelectTrigger className="w-32 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</HelpTip>
<div className="flex-1" />
<HelpTip label="Discard and cancel">
<Button
@@ -964,10 +1002,12 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
}
actions={
!isAdding && (
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Add a new open question">
<Button size="sm" variant="ghost" onClick={() => setIsAdding(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)
}
>
@@ -1051,12 +1091,14 @@ function OpenQuestionsSection({ task, plan }: { task: Task; plan: TaskPlan }) {
</div>
) : (
<div className="ml-7">
<Badge
variant="outline"
className="text-yellow-600 border-yellow-300"
>
Awaiting Answer
</Badge>
<HelpTip label="No answer recorded yet — click this card to add one">
<Badge
variant="outline"
className="text-yellow-600 border-yellow-300"
>
Awaiting Answer
</Badge>
</HelpTip>
</div>
)}
</div>
@@ -1157,10 +1199,12 @@ export function TabPlan({ task }: TabPlanProps) {
A plan will be created once an agent claims and starts working on
this task.
</p>
<Button onClick={createPlan} disabled={updateTask.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Plan
</Button>
<HelpTip label="Creates an empty plan object on this task so approach/sub-tasks/risks can be filled in manually">
<Button onClick={createPlan} disabled={updateTask.isPending}>
<Plus className="h-4 w-4 mr-2" />
Create Plan
</Button>
</HelpTip>
</div>
</CardContent>
</Card>
@@ -30,6 +30,7 @@ import { toast } from "sonner";
import { getAgentDisplayName } from "@/lib/agent-utils";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { exceedsReadabilityThreshold } from "@/lib/content-readability";
import { HelpTip } from "@/components/ui/help-tip";
/**
* Only the 2 most recent entries default to expanded; older entries (and
@@ -159,29 +160,35 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Progress Updates</CardTitle>
<HelpTip label="A timestamped log of status updates entered by devs, QA, or the CEO">
<CardTitle className="text-lg">Progress Updates</CardTitle>
</HelpTip>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{updates.length} update{updates.length !== 1 ? "s" : ""}
</span>
{!isAdding && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Log a new progress update, optionally with a completion percentage">
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)}
</div>
</div>
{latestWithPercentage && (
<div className="mt-2">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-medium">{currentProgress}%</span>
</div>
<HelpTip label="The percentage from the most recent update that included one — manually entered, not computed from acceptance criteria">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">Overall Progress</span>
<span className="font-medium">{currentProgress}%</span>
</div>
</HelpTip>
<Progress value={currentProgress} className="h-2" />
</div>
)}
@@ -197,35 +204,41 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
className="text-sm min-h-[80px]"
/>
<div className="flex items-center gap-2">
<Input
type="number"
value={newPercentage}
onChange={(e) => setNewPercentage(e.target.value)}
placeholder="Progress %"
className="w-32 h-8 text-sm"
min="0"
max="100"
/>
<HelpTip label="Optional — leave blank to log a note with no percentage">
<Input
type="number"
value={newPercentage}
onChange={(e) => setNewPercentage(e.target.value)}
placeholder="Progress %"
className="w-32 h-8 text-sm"
min="0"
max="100"
/>
</HelpTip>
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewMessage("");
setNewPercentage("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!newMessage.trim() || updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Add Update
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewMessage("");
setNewPercentage("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save this progress update to the task">
<Button
size="sm"
onClick={handleAdd}
disabled={!newMessage.trim() || updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Add Update
</Button>
</HelpTip>
</div>
</div>
)}
@@ -253,9 +266,16 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
className="bg-muted/50 rounded-lg p-3"
>
<div className="flex items-center justify-between mb-1">
{/* CollapsibleTrigger is a stateful Radix trigger HelpTip's
asChild Slot would clobber its open/closed data-state
(same trap as task-tabs.tsx), and this Collapsible is
uncontrolled so there's no local state to re-assert.
Left untipped; the chevron + agent name are visually
self-explanatory as an expand/collapse row. */}
<CollapsibleTrigger asChild>
<button
type="button"
data-testid="progress-update-trigger"
className="group/trigger flex min-w-0 items-center gap-1 text-left"
>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=closed]/trigger:-rotate-90" />
@@ -273,14 +293,16 @@ function ProgressUpdatesSection({ task }: { task: Task }) {
{formatTime(update.timestamp)} ·{" "}
{formatAbsoluteTimestamp(update.timestamp)}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(idx)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<HelpTip label="Delete this progress update">
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(idx)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div>
</div>
<CollapsibleContent>
@@ -382,23 +404,27 @@ function CheckpointsSection({ task }: { task: Task }) {
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Checkpoints
</CardTitle>
<HelpTip label="A saved snapshot of task state + remaining work, useful for handoff between agents">
<CardTitle className="text-lg flex items-center gap-2">
<Bookmark className="h-5 w-5" />
Checkpoints
</CardTitle>
</HelpTip>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{checkpoints.length} saved
</span>
{!isAdding && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
<HelpTip label="Save a new checkpoint of the current state and remaining work">
<Button
size="sm"
variant="ghost"
onClick={() => setIsAdding(true)}
>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</HelpTip>
)}
</div>
</div>
@@ -442,26 +468,30 @@ function CheckpointsSection({ task }: { task: Task }) {
</div>
<div className="flex items-center gap-2">
<div className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewSummary("");
setNewRemaining("");
setNewNotes("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={handleAdd}
disabled={!newSummary.trim() || updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save Checkpoint
</Button>
<HelpTip label="Discard and cancel">
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewSummary("");
setNewRemaining("");
setNewNotes("");
setIsAdding(false);
}}
>
<X className="h-4 w-4" />
</Button>
</HelpTip>
<HelpTip label="Save this checkpoint to the task">
<Button
size="sm"
onClick={handleAdd}
disabled={!newSummary.trim() || updateTask.isPending}
>
<Check className="h-4 w-4 mr-1" />
Save Checkpoint
</Button>
</HelpTip>
</div>
</div>
)}
@@ -491,6 +521,8 @@ function CheckpointsSection({ task }: { task: Task }) {
defaultOpen={defaultEntryOpen(idx, checkpointContent)}
>
<div className="bg-primary/10 px-4 py-2 flex items-center justify-between">
{/* Uncontrolled CollapsibleTrigger left untipped, see
the identical note in ProgressUpdatesSection above. */}
<CollapsibleTrigger asChild>
<button
type="button"
@@ -512,14 +544,16 @@ function CheckpointsSection({ task }: { task: Task }) {
{formatTime(checkpoint.timestamp)} ·{" "}
{formatAbsoluteTimestamp(checkpoint.timestamp)}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(checkpoint.id)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
<HelpTip label="Delete this checkpoint">
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(checkpoint.id)}
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</HelpTip>
</div>
</div>
<CollapsibleContent>
@@ -61,7 +61,11 @@ export function EscalateToCeoDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason">Reason for escalation</Label>
<HelpTip label="Shown to the CEO in their approval queue alongside this task">
<Label htmlFor="reason" className="w-fit">
Reason for escalation
</Label>
</HelpTip>
<Textarea
id="reason"
value={reason}
@@ -72,15 +76,24 @@ export function EscalateToCeoDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={!reason.trim() || isPending}
>
{isPending ? "Escalating..." : "Escalate"}
</Button>
<HelpTip label="Closes without escalating; the task stays in its current status">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
<HelpTip label={!reason.trim() ? "Enter a reason to enable" : null}>
<span
className="inline-block"
tabIndex={!reason.trim() ? 0 : undefined}
>
<Button
onClick={handleConfirm}
disabled={!reason.trim() || isPending}
>
{isPending ? "Escalating..." : "Escalate"}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -126,7 +139,11 @@ export function CeoRejectDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="notes">Required changes</Label>
<HelpTip label="Recorded as the CEO's rejection reason; the task returns to needs_revision">
<Label htmlFor="notes" className="w-fit">
Required changes
</Label>
</HelpTip>
<Textarea
id="notes"
value={notes}
@@ -137,16 +154,25 @@ export function CeoRejectDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={!notes.trim() || isPending}
>
{isPending ? "Submitting..." : "Request Changes"}
</Button>
<HelpTip label="Closes without rejecting; the task stays in awaiting CEO approval">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
<HelpTip label={!notes.trim() ? "Explain what changes are needed to enable" : null}>
<span
className="inline-block"
tabIndex={!notes.trim() ? 0 : undefined}
>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={!notes.trim() || isPending}
>
{isPending ? "Submitting..." : "Request Changes"}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -179,13 +205,15 @@ export function ApproveAndMergeDialog({
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
<HelpTip label="Closes without approving or merging">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
</HelpTip>
<Button onClick={onConfirm} disabled={isPending}>
{isPending ? "Merging..." : "Approve & Merge"}
</Button>
@@ -263,14 +291,20 @@ export function CeoApproveDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<HelpTip label={remainingCharsTip(notes.trim().length, _CEO_NOTES_MIN)}>
<Button onClick={handleConfirm} disabled={tooShort || isPending}>
{isPending ? "Approving..." : "Approve & Merge"}
<HelpTip label="Closes without approving; the task stays in awaiting CEO approval">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
{/* Button's own disabled:pointer-events-none would swallow hover, so
the tip sits on a wrapping span rather than the Button itself. */}
<HelpTip label={remainingCharsTip(notes.trim().length, _CEO_NOTES_MIN)}>
<span className="inline-block" tabIndex={tooShort ? 0 : undefined}>
<Button onClick={handleConfirm} disabled={tooShort || isPending}>
{isPending ? "Approving..." : "Approve & Merge"}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -344,18 +378,24 @@ export function RequiredNotesDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<HelpTip label={remainingCharsTip(text.trim().length, minChars)}>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={tooShort || isPending}
>
{isPending ? "Working..." : confirmLabel}
<HelpTip label="Closes without confirming; nothing is changed">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
{/* Button's own disabled:pointer-events-none would swallow hover, so
the tip sits on a wrapping span rather than the Button itself. */}
<HelpTip label={remainingCharsTip(text.trim().length, minChars)}>
<span className="inline-block" tabIndex={tooShort ? 0 : undefined}>
<Button
variant={destructive ? "destructive" : "default"}
onClick={handleConfirm}
disabled={tooShort || isPending}
>
{isPending ? "Working..." : confirmLabel}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -401,7 +441,11 @@ export function CreateBranchDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="branch-type">Branch Type</Label>
<HelpTip label="Chooses the branch-name prefix — see the naming preview below">
<Label htmlFor="branch-type" className="w-fit">
Branch Type
</Label>
</HelpTip>
<Select value={branchType} onValueChange={setBranchType}>
<SelectTrigger>
<SelectValue />
@@ -420,12 +464,16 @@ export function CreateBranchDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={isPending}>
{isPending ? "Creating..." : "Create Branch"}
</Button>
<HelpTip label="Closes without creating a branch">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
<HelpTip label="Creates and checks out the branch immediately — no confirmation after this">
<Button onClick={handleConfirm} disabled={isPending}>
{isPending ? "Creating..." : "Create Branch"}
</Button>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -482,7 +530,11 @@ export function CreatePRDialog({
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="pr-title">Title</Label>
<HelpTip label="Seeded from the task title by default — edit before creating">
<Label htmlFor="pr-title" className="w-fit">
Title
</Label>
</HelpTip>
<Input
id="pr-title"
value={title}
@@ -491,7 +543,11 @@ export function CreatePRDialog({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pr-body">Description</Label>
<HelpTip label="Becomes the pull request's description on GitHub">
<Label htmlFor="pr-body" className="w-fit">
Description
</Label>
</HelpTip>
<Textarea
id="pr-body"
value={body}
@@ -502,12 +558,24 @@ export function CreatePRDialog({
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!title.trim() || isPending}>
{isPending ? "Creating..." : "Create PR"}
</Button>
<HelpTip label="Closes without creating a PR">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
</Button>
</HelpTip>
<HelpTip label={!title.trim() ? "Enter a title to enable" : null}>
<span
className="inline-block"
tabIndex={!title.trim() ? 0 : undefined}
>
<Button
onClick={handleConfirm}
disabled={!title.trim() || isPending}
>
{isPending ? "Creating..." : "Create PR"}
</Button>
</span>
</HelpTip>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -3,6 +3,7 @@
import { Task } from "@/types";
import { useTask } from "@/hooks/use-tasks";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
@@ -28,14 +29,16 @@ export function TaskBreadcrumb({ task }: TaskBreadcrumbProps) {
{isLoading || !parent ? (
<Skeleton className="h-4 w-32" />
) : (
<Link
href={`/tasks/${parent.id}`}
prefetch={false}
className="truncate hover:text-foreground hover:underline"
title={parent.title}
>
{parent.title}
</Link>
<HelpTip label="Go to the parent task — only the immediate parent is shown here, not the full ancestry chain">
<Link
href={`/tasks/${parent.id}`}
prefetch={false}
className="truncate hover:text-foreground hover:underline"
title={parent.title}
>
{parent.title}
</Link>
</HelpTip>
)}
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-foreground/70" title={task.title}>
@@ -189,7 +189,9 @@ export function TaskDescription({ task }: TaskDescriptionProps) {
defaultOpen={false}
title={
<>
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<HelpTip label="Read-only architectural rules derived from the project's conventions — auto-attached, not editable here">
<ShieldAlert className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</HelpTip>
<span className="text-amber-800 dark:text-amber-300">
Constraints
</span>
@@ -56,6 +56,7 @@ import {
import { toast } from "sonner";
import { TaskTypeBadge } from "../task-type-badge";
import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import {
Tooltip,
TooltipContent,
@@ -117,6 +118,55 @@ const statusLabels: Record<TaskStatus, string> = {
[TaskStatus.CANCELLED]: "Cancelled",
};
// Per-action explanation of the real backend consequence — grounded in
// roboco/foundation/policy/lifecycle.py (ActionSpec/IntentSpec source +
// target statuses, allowed_roles) and the handlers in
// app/(dashboard)/tasks/[taskId]/page.tsx (handleAction). Some of these
// route through an admin-override PATCH rather than a dedicated lifecycle
// verb — the tip says so where that's the case.
const ACTION_TIPS: Record<string, string> = {
claim: "Locks this task to you so no one else can claim it (pending → claimed).",
start: "Begins active work on the task (claimed → in_progress).",
"create-branch":
"Creates the git branch for this task; status does not change.",
pause:
"Pauses active work (in_progress → paused); use Resume to continue later.",
block:
"Flags an external blocker (in_progress → blocked); only a PM can unblock it.",
"create-pr":
"Opens the pull request for this task's branch; status does not change.",
verify: "Marks work self-verified, the step before QA (in_progress → verifying).",
unblock: "PM-only: clears the blocker and resumes work (blocked → in_progress).",
resume: "Resumes paused work (paused → in_progress).",
"submit-qa": "Hands the task to QA for review (verifying → awaiting_qa).",
"pass-qa":
"QA-only: passes review and starts docs (awaiting_qa → awaiting_documentation).",
"fail-qa":
"QA-only: sends the task back to the developer (awaiting_qa → needs_revision).",
"docs-complete":
"Documenter-only: marks docs done, moves to PM review (→ awaiting_pm_review).",
"submit-pm-review":
"Sends the finished work to the PM for merge review (→ awaiting_pm_review).",
complete:
"PM-only: approves and marks the task completed (awaiting_pm_review → completed).",
"escalate-to-ceo":
"Sends this task to the CEO for final sign-off (→ awaiting_ceo_approval).",
"request-changes":
"Sends the task back to the developer for changes (→ needs_revision).",
"ceo-approve":
"CEO-only: approves and completes the task (→ completed); requires a note.",
"approve-and-merge":
"CEO-only: merges the PR and marks the task completed in one step.",
"ceo-reject": "CEO-only: sends the task back for revision (→ needs_revision).",
reopen: "Admin override: force-sets a cancelled task back to pending.",
activate: "PM-only: releases the task from backlog into the claim pool (→ pending).",
"start-revision":
"Admin override: forces the task straight to in_progress, skipping re-claim.",
cancel: "PM/CEO-only: cancels the task permanently; requires a documented reason.",
"merge-pr":
"Merges this task's PR on GitHub; the backend updates task state as a side effect.",
};
interface TaskHeaderProps {
task: Task;
onAction?: (action: string) => void;
@@ -515,13 +565,14 @@ export function TaskHeader({ task, onAction, nav }: TaskHeaderProps) {
disabled={updateTask.isPending}
/>
) : (
<h1
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors truncate"
onClick={startEditingTitle}
title={task.title}
>
{task.title}
</h1>
<HelpTip label={`Click to rename — full title: "${task.title}"`}>
<h1
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors truncate"
onClick={startEditingTitle}
>
{task.title}
</h1>
</HelpTip>
)}
{/* Row 2: copyable task id + status + team + type. The dropdowns are
@@ -530,13 +581,12 @@ export function TaskHeader({ task, onAction, nav }: TaskHeaderProps) {
flex-wrap: on narrow viewports the id/status/team/type group
wraps to further rows instead of overflowing horizontally. */}
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<span
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2.5 font-mono text-sm text-muted-foreground"
title={task.id}
>
#{task.id.slice(0, 8)}
<CopyButton value={task.id} className="-mr-1 px-1 py-0" />
</span>
<HelpTip label={`Full task ID: ${task.id}`}>
<span className="inline-flex h-7 shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2.5 font-mono text-sm text-muted-foreground">
#{task.id.slice(0, 8)}
<CopyButton value={task.id} className="-mr-1 px-1 py-0" />
</span>
</HelpTip>
<span className="text-muted-foreground shrink-0">|</span>
@@ -669,42 +719,49 @@ export function TaskHeader({ task, onAction, nav }: TaskHeaderProps) {
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
{/* Lifecycle actions (non-cancel) */}
{/* Lifecycle actions (non-cancel) each tip states the real
sourcetarget status transition and which role can fire
it, grounded in lifecycle.py's ActionSpec/IntentSpec table. */}
{actions
.filter((a) => a.action !== "cancel")
.map((action) => (
<DropdownMenuItem
key={action.action}
onClick={() => handleAction(action.action)}
>
{action.icon}
{action.label}
</DropdownMenuItem>
<HelpTip key={action.action} label={ACTION_TIPS[action.action]}>
<DropdownMenuItem
onClick={() => handleAction(action.action)}
>
{action.icon}
{action.label}
</DropdownMenuItem>
</HelpTip>
))}
{/* Cancel action */}
{actions.some((a) => a.action === "cancel") && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel Task
</DropdownMenuItem>
<HelpTip label={ACTION_TIPS.cancel}>
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel Task
</DropdownMenuItem>
</HelpTip>
</>
)}
{/* Delete option */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</DropdownMenuItem>
<HelpTip label="Permanently deletes this task record from the database; cannot be undone.">
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</DropdownMenuItem>
</HelpTip>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -31,6 +31,7 @@ import {
import { toast } from "sonner";
import { getAgentDisplayName, resolveToSlug } from "@/lib/agent-utils";
import { branchUrl } from "@/lib/repo-url";
import { formatAbsoluteTimestamp } from "@/lib/utils";
import { CopyButton } from "@/components/ui/copy-button";
import { HelpTip } from "@/components/ui/help-tip";
import { TaskTypeBadge } from "../task-type-badge";
@@ -232,28 +233,30 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<AlertTriangle className="h-4 w-4" />
Priority
</div>
<Select
value={task.priority.toString()}
onValueChange={handlePriorityChange}
disabled={updateTask.isPending}
>
<SelectTrigger
className={`w-full h-8 text-sm border-0 ${priorityColors[task.priority] ?? priorityColors[2]}`}
<HelpTip label="How urgently this should be worked — P0 is highest, P3 is lowest">
<Select
value={task.priority.toString()}
onValueChange={handlePriorityChange}
disabled={updateTask.isPending}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(priorityLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
<span
className={`px-2 py-0.5 rounded ${priorityColors[parseInt(value)]}`}
>
{label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<SelectTrigger
className={`w-full h-8 text-sm border-0 ${priorityColors[task.priority] ?? priorityColors[2]}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(priorityLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
<span
className={`px-2 py-0.5 rounded ${priorityColors[parseInt(value)]}`}
>
{label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</HelpTip>
</CardContent>
</Card>
@@ -264,22 +267,24 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Target className="h-4 w-4" />
Complexity
</div>
<Select
value={task.estimated_complexity}
onValueChange={handleComplexityChange}
disabled={updateTask.isPending}
>
<SelectTrigger className="w-full h-8 text-sm border-0 bg-transparent hover:bg-muted/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Complexity).map((complexity) => (
<SelectItem key={complexity} value={complexity}>
{complexityLabels[complexity]}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpTip label="A manual estimate of implementation effort; not derived from the plan or ACs">
<Select
value={task.estimated_complexity}
onValueChange={handleComplexityChange}
disabled={updateTask.isPending}
>
<SelectTrigger className="w-full h-8 text-sm border-0 bg-transparent hover:bg-muted/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Complexity).map((complexity) => (
<SelectItem key={complexity} value={complexity}>
{complexityLabels[complexity]}
</SelectItem>
))}
</SelectContent>
</Select>
</HelpTip>
</CardContent>
</Card>
@@ -302,13 +307,14 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
disabled={updateTask.isPending}
/>
) : (
<span
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
onClick={startEditingAssigned}
title="Click to edit"
>
{getAgentDisplayName(task.assigned_to)}
</span>
<HelpTip label="Click to reassign — enter an agent slug (e.g. be-dev-1), or clear to unassign">
<span
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
onClick={startEditingAssigned}
>
{getAgentDisplayName(task.assigned_to)}
</span>
</HelpTip>
)}
</CardContent>
</Card>
@@ -320,9 +326,11 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<User className="h-4 w-4" />
Created By
</div>
<span className="font-medium">
{getAgentDisplayName(task.created_by)}
</span>
<HelpTip label="The agent or human who originally created this task record">
<span className="font-medium">
{getAgentDisplayName(task.created_by)}
</span>
</HelpTip>
</CardContent>
</Card>
@@ -348,9 +356,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Calendar className="h-4 w-4" />
Created
</div>
<span className="font-medium">
{formatRelativeTime(task.created_at)}
</span>
<HelpTip
label={
task.created_at
? `Exact timestamp: ${formatAbsoluteTimestamp(task.created_at)}`
: ""
}
>
<span className="font-medium">
{formatRelativeTime(task.created_at)}
</span>
</HelpTip>
</CardContent>
</Card>
@@ -361,9 +377,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Clock className="h-4 w-4" />
Started
</div>
<span className="font-medium">
{formatRelativeTime(task.started_at)}
</span>
<HelpTip
label={
task.started_at
? `Exact timestamp: ${formatAbsoluteTimestamp(task.started_at)}`
: ""
}
>
<span className="font-medium">
{formatRelativeTime(task.started_at)}
</span>
</HelpTip>
</CardContent>
</Card>
@@ -386,13 +410,14 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
disabled={updateTask.isPending}
/>
) : (
<span
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
onClick={startEditingTargetDate}
title="Click to edit"
>
{task.target_date ? formatDate(task.target_date) : "Not set"}
</span>
<HelpTip label="Click to set/change the target completion date">
<span
className="font-medium cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors inline-block"
onClick={startEditingTargetDate}
>
{task.target_date ? formatDate(task.target_date) : "Not set"}
</span>
</HelpTip>
)}
</CardContent>
</Card>
@@ -404,9 +429,17 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<Clock className="h-4 w-4" />
Completed
</div>
<span className="font-medium">
{formatRelativeTime(task.completed_at)}
</span>
<HelpTip
label={
task.completed_at
? `Exact timestamp: ${formatAbsoluteTimestamp(task.completed_at)}`
: ""
}
>
<span className="font-medium">
{formatRelativeTime(task.completed_at)}
</span>
</HelpTip>
</CardContent>
</Card>
@@ -462,21 +495,24 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
</div>
<div className="flex items-center gap-1">
{branchHref ? (
<a
href={branchHref}
target="_blank"
rel="noopener noreferrer"
title="Open branch in GitHub"
className="inline-flex transition-opacity hover:opacity-80"
>
<HelpTip label="Open this branch on GitHub in a new tab">
<a
href={branchHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex transition-opacity hover:opacity-80"
>
<Badge variant="outline" className="font-mono text-sm">
{task.branch_name}
</Badge>
</a>
</HelpTip>
) : (
<HelpTip label="The git branch for this task; no project git_url configured to link out to it">
<Badge variant="outline" className="font-mono text-sm">
{task.branch_name}
</Badge>
</a>
) : (
<Badge variant="outline" className="font-mono text-sm">
{task.branch_name}
</Badge>
</HelpTip>
)}
<CopyButton value={task.branch_name} />
</div>
@@ -493,17 +529,21 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
Pull Request
</div>
{task.pr_url ? (
<a
href={task.pr_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
PR #{task.pr_number}
<ExternalLink className="h-3 w-3" />
</a>
<HelpTip label="Opens this task's pull request on GitHub in a new tab">
<a
href={task.pr_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-blue-600 hover:underline dark:text-blue-400"
>
PR #{task.pr_number}
<ExternalLink className="h-3 w-3" />
</a>
</HelpTip>
) : (
<span className="font-medium">#{task.pr_number}</span>
<HelpTip label="PR number on record; no pr_url stored to link out to it">
<span className="font-medium">#{task.pr_number}</span>
</HelpTip>
)}
</CardContent>
</Card>
@@ -517,15 +557,19 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
Project
</div>
{task.project_id && project ? (
<Link
prefetch={false}
href={`/projects`}
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{project.name}
</Link>
<HelpTip label="Opens the Projects list (not a deep link to this specific project)">
<Link
prefetch={false}
href={`/projects`}
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{project.name}
</Link>
</HelpTip>
) : (
<span className="font-medium text-muted-foreground">-</span>
<HelpTip label="This task has no associated project">
<span className="font-medium text-muted-foreground">-</span>
</HelpTip>
)}
</CardContent>
</Card>
@@ -191,16 +191,18 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{session.branch_name}
</code>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Base:</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
{session.base_branch}
</code>
<span></span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
{session.target_branch}
</code>
</div>
<HelpTip label="Base is the branch this session forked from; target is where its PR merges into">
<div className="flex items-center gap-2 text-sm text-muted-foreground w-fit">
<span>Base:</span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
{session.base_branch}
</code>
<span></span>
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
{session.target_branch}
</code>
</div>
</HelpTip>
</div>
{/* PR Info */}
@@ -226,36 +228,42 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
</div>
</div>
{session.pr_url && (
<Button variant="outline" size="sm" asChild>
<a
href={session.pr_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
View PR
</a>
</Button>
<HelpTip label="Opens the pull request on GitHub in a new tab">
<Button variant="outline" size="sm" asChild>
<a
href={session.pr_url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4 mr-1" />
View PR
</a>
</Button>
</HelpTip>
)}
</div>
)}
{/* Stats Row */}
<div className="flex items-center gap-4 pt-2">
<div className="flex items-center gap-2">
<GitCommit className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{session.commits.length} commit
{session.commits.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-2">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{session.files_modified.length} file
{session.files_modified.length !== 1 ? "s" : ""}
</span>
</div>
<HelpTip label="Commits recorded against this branch's work session">
<div className="flex items-center gap-2 w-fit">
<GitCommit className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{session.commits.length} commit
{session.commits.length !== 1 ? "s" : ""}
</span>
</div>
</HelpTip>
<HelpTip label="Distinct files touched across this session's commits">
<div className="flex items-center gap-2 w-fit">
<FileCode className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{session.files_modified.length} file
{session.files_modified.length !== 1 ? "s" : ""}
</span>
</div>
</HelpTip>
<HelpTip label={formatAbsoluteTimestamp(session.started_at)}>
<div className="text-sm text-muted-foreground ml-auto w-fit">
Started{" "}
@@ -268,12 +276,14 @@ export function WorkSessionCard({ taskId }: WorkSessionCardProps) {
{/* View Full Session Link */}
<div className="flex justify-end pt-2">
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="h-3 w-3" />
View Details
</Button>
</Link>
<HelpTip label="Opens the full work-session record — branch, every commit, and status history">
<Link href={`/work-sessions/${session.id}`} prefetch={false}>
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="h-3 w-3" />
View Details
</Button>
</Link>
</HelpTip>
</div>
</CardContent>
</Card>
+102 -88
View File
@@ -143,27 +143,31 @@ export function TaskFilters({
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
<HelpTip label="Debounced server-side search across title, description, and task ID prefix.">
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</HelpTip>
</div>
<div className="flex flex-wrap gap-2">
{/* Status Multi-Select */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{statusFilter.length === 0
? "All Statuses"
: statusFilter.length === 1
? STATUS_LABELS[statusFilter[0]]
: `${statusFilter.length} statuses`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<HelpTip label="Multi-select — pick any number of statuses; the list shows tasks matching any of them.">
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{statusFilter.length === 0
? "All Statuses"
: statusFilter.length === 1
? STATUS_LABELS[statusFilter[0]]
: `${statusFilter.length} statuses`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-56 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Status</span>
@@ -197,18 +201,20 @@ export function TaskFilters({
{/* Team Multi-Select */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{teamFilter.length === 0
? "All Teams"
: teamFilter.length === 1
? TEAM_LABELS[teamFilter[0]]
: `${teamFilter.length} teams`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<HelpTip label="Multi-select — pick any number of teams; the list shows tasks matching any of them.">
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-32 justify-between">
<span className="truncate">
{teamFilter.length === 0
? "All Teams"
: teamFilter.length === 1
? TEAM_LABELS[teamFilter[0]]
: `${teamFilter.length} teams`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-48 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Team</span>
@@ -243,21 +249,23 @@ export function TaskFilters({
{/* Task Type Multi-Select (optional) */}
{onTaskTypeChange && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{taskTypeFilter.length === 0
? "All Types"
: taskTypeFilter.length === 1
? TASK_TYPE_LABELS[taskTypeFilter[0]]
: `${taskTypeFilter.length} types`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<HelpTip label="Multi-select — pick any number of task types; the list shows tasks matching any of them.">
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{taskTypeFilter.length === 0
? "All Types"
: taskTypeFilter.length === 1
? TASK_TYPE_LABELS[taskTypeFilter[0]]
: `${taskTypeFilter.length} types`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-48 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Task Type</span>
@@ -295,21 +303,23 @@ export function TaskFilters({
{/* Project Multi-Select (optional) */}
{onProjectChange && projectOptions.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{projectFilter.length === 0
? "All Projects"
: projectFilter.length === 1
? projectLabel(projectFilter[0])
: `${projectFilter.length} projects`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<HelpTip label="Multi-select — pick any number of projects; the list shows tasks matching any of them.">
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{projectFilter.length === 0
? "All Projects"
: projectFilter.length === 1
? projectLabel(projectFilter[0])
: `${projectFilter.length} projects`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-56 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Project</span>
@@ -345,21 +355,23 @@ export function TaskFilters({
{/* Product Multi-Select (optional) */}
{onProductChange && productOptions.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{productFilter.length === 0
? "All Products"
: productFilter.length === 1
? productLabel(productFilter[0])
: `${productFilter.length} products`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<HelpTip label="Multi-select — pick any number of products; the list shows tasks matching any of them.">
<PopoverTrigger asChild>
<Button
variant="outline"
className="min-w-32 justify-between"
>
<span className="truncate">
{productFilter.length === 0
? "All Products"
: productFilter.length === 1
? productLabel(productFilter[0])
: `${productFilter.length} products`}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
</HelpTip>
<PopoverContent className="w-56 p-2" align="start">
<div className="flex items-center justify-between mb-2 pb-2 border-b">
<span className="text-sm font-medium">Product</span>
@@ -466,20 +478,22 @@ export function TaskFilters({
taskTypeFilter.length > 0 ||
projectFilter.length > 0 ||
productFilter.length > 0) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
clearStatuses();
clearTeams();
clearTaskTypes();
clearProjects();
clearProducts();
}}
>
Clear all
</Button>
<HelpTip label="Removes every active filter above, restoring the unfiltered task list.">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
clearStatuses();
clearTeams();
clearTaskTypes();
clearProjects();
clearProducts();
}}
>
Clear all
</Button>
</HelpTip>
)}
</div>
)}
+6 -4
View File
@@ -151,10 +151,12 @@ export function TaskSelector({
<SelectContent className="max-h-[300px]">
{allowClear && value && (
<SelectItem value="__clear__" className="text-muted-foreground">
<span className="flex items-center gap-2">
<X className="h-4 w-4" />
No parent (standalone task)
</span>
<HelpTip label="Clears the parent link — this becomes a standalone root task.">
<span className="flex items-center gap-2">
<X className="h-4 w-4" />
No parent (standalone task)
</span>
</HelpTip>
</SelectItem>
)}
+179 -101
View File
@@ -244,16 +244,20 @@ function SortableHeader({
return (
<TableHead className={className}>
<Button
onClick={() => onSort(field)}
variant="ghost"
className="h-auto -ml-2 px-2 py-1 gap-1 font-normal"
>
{label}
{!isActive && <ArrowUpDown className="h-4 w-4 text-muted-foreground" />}
{direction === "asc" && <ArrowUp className="h-4 w-4" />}
{direction === "desc" && <ArrowDown className="h-4 w-4" />}
</Button>
<HelpTip label="Sorts ascending, then descending, then clears — click again to cycle.">
<Button
onClick={() => onSort(field)}
variant="ghost"
className="h-auto -ml-2 px-2 py-1 gap-1 font-normal"
>
{label}
{!isActive && (
<ArrowUpDown className="h-4 w-4 text-muted-foreground" />
)}
{direction === "asc" && <ArrowUp className="h-4 w-4" />}
{direction === "desc" && <ArrowDown className="h-4 w-4" />}
</Button>
</HelpTip>
</TableHead>
);
}
@@ -473,22 +477,26 @@ export function TaskTable({
{hasAnyChildren && !isLoading && (
<div className="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
<span className="text-sm text-muted-foreground">Tree view:</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={expandAll}
>
Expand all
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={collapseAll}
>
Collapse all
</Button>
<HelpTip label="Opens every parent row so all subtasks show inline, ignoring current sort.">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={expandAll}
>
Expand all
</Button>
</HelpTip>
<HelpTip label="Hides every subtask row, showing only root-level tasks.">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={collapseAll}
>
Collapse all
</Button>
</HelpTip>
</div>
)}
@@ -510,7 +518,9 @@ export function TaskTable({
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">Git</TableHead>
<HelpTip label="Branch, PR, or docs/PR progress — whichever this task's git workflow has reached.">
<TableHead className="whitespace-nowrap">Git</TableHead>
</HelpTip>
<SortableHeader
label="Team"
field="team"
@@ -518,9 +528,11 @@ export function TaskTable({
onSort={handleSort}
className="whitespace-nowrap"
/>
<TableHead className="whitespace-nowrap">
Project / Product
</TableHead>
<HelpTip label="A direct Project shows its name; a fan-out task shows its Product instead.">
<TableHead className="whitespace-nowrap">
Project / Product
</TableHead>
</HelpTip>
<SortableHeader
label="Priority"
field="priority"
@@ -589,19 +601,27 @@ export function TaskTable({
style={{ paddingLeft: `${node.depth * 1.5}rem` }}
>
{hasChildren ? (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="p-0.5 h-5 w-5 shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
) : (
<span className="w-5 shrink-0" />
)}
@@ -625,13 +645,15 @@ export function TaskTable({
</HelpTip>
)}
{childCount > 0 && (
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge
variant="secondary"
className="text-xs shrink-0"
>
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
</Link>
@@ -677,14 +699,28 @@ export function TaskTable({
</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<TaskActions task={task} />
@@ -720,19 +756,27 @@ export function TaskTable({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{hasChildren && (
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
<HelpTip
label={
isExpanded
? "Hides this task's subtasks"
: "Shows this task's subtasks inline"
}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => toggleExpand(task.id)}
variant="ghost"
size="icon-sm"
className="h-5 w-5 shrink-0 p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
</Button>
</HelpTip>
)}
<Link
prefetch={false}
@@ -757,10 +801,12 @@ export function TaskTable({
</HelpTip>
)}
{childCount > 0 && (
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
<HelpTip label="Direct subtasks under this task, regardless of their current status.">
<Badge variant="secondary" className="text-xs">
{childCount} subtask
{childCount !== 1 ? "s" : ""}
</Badge>
</HelpTip>
)}
</div>
) : null}
@@ -805,14 +851,28 @@ export function TaskTable({
</Badge>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Assigned">
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
<HelpTip
label={
task.assigned_to
? "The agent currently pinned to this task."
: "No agent pinned — the orchestrator routes it by role and availability."
}
>
<Badge variant="outline">
{getAgentDisplayName(task.assigned_to)}
</Badge>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Created">
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
<HelpTip
label={new Date(task.created_at).toLocaleString()}
>
<span>
{formatDistanceToNow(new Date(task.created_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>
@@ -827,7 +887,9 @@ export function TaskTable({
{!isLoading && tasks && tasks.length > 0 && (
<div className="flex flex-wrap items-center justify-end gap-4 px-4 py-3 border-t">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Rows:</span>
<HelpTip label="How many rows show per page; resets to page 1 when changed.">
<span>Rows:</span>
</HelpTip>
<Select
value={String(pageSize)}
onValueChange={handlePageSizeChange}
@@ -844,30 +906,46 @@ export function TaskTable({
</SelectContent>
</Select>
</div>
<span className="text-sm text-muted-foreground">
{startIndex + 1}-{endIndex} of {totalItems}
</span>
<HelpTip label="Range within the current filtered/sorted, flattened tree view — not the total unfiltered task count.">
<span className="text-sm text-muted-foreground">
{startIndex + 1}-{endIndex} of {totalItems}
</span>
</HelpTip>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
<HelpTip label="Previous page.">
<span
className="inline-block"
tabIndex={currentPage === 1 ? 0 : undefined}
>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
</span>
</HelpTip>
<HelpTip label="Next page.">
<span
className="inline-block"
tabIndex={currentPage === totalPages ? 0 : undefined}
>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</span>
</HelpTip>
</div>
</div>
)}
+21 -16
View File
@@ -2,11 +2,7 @@
import { TaskType } from "@/types";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { HelpTip } from "@/components/ui/help-tip";
import {
Code,
FileText,
@@ -22,6 +18,18 @@ interface TaskTypeBadgeProps {
className?: string;
}
// What each task type classifies as its artifact kind. Every type still
// follows the identical full git workflow (branch, commits, PR) — this is
// classification only, not a different pipeline.
const TYPE_DESCRIPTIONS: Record<TaskType, string> = {
[TaskType.CODE]: "Source code changes.",
[TaskType.DOCUMENTATION]: "Documentation updates.",
[TaskType.RESEARCH]: "Research findings, committed as notes.",
[TaskType.PLANNING]: "Plans or architecture, committed as docs.",
[TaskType.DESIGN]: "Designs or specs, committed as assets.",
[TaskType.ADMINISTRATIVE]: "Process docs, committed as notes.",
};
const TYPE_CONFIG: Record<
TaskType,
{ label: string; icon: React.ReactNode; color: string }
@@ -71,16 +79,13 @@ export function TaskTypeBadge({
if (!config) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className={`${config.color} ${className}`}>
<span className="flex items-center gap-1">
{config.icon}
{showLabel && <span>{config.label}</span>}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent>Task type: {config.label}</TooltipContent>
</Tooltip>
<HelpTip label={TYPE_DESCRIPTIONS[type]}>
<Badge variant="outline" className={`${config.color} ${className}`}>
<span className="flex items-center gap-1">
{config.icon}
{showLabel && <span>{config.label}</span>}
</span>
</Badge>
</HelpTip>
);
}
@@ -13,6 +13,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Search, Filter, X } from "lucide-react";
import { WorkSessionStatus } from "@/types";
import { HelpTip } from "@/components/ui/help-tip";
interface WorkSessionFiltersProps {
searchQuery: string;
@@ -21,10 +22,22 @@ interface WorkSessionFiltersProps {
onStatusChange: (value: WorkSessionStatus[]) => void;
}
const statuses: { value: WorkSessionStatus; label: string }[] = [
{ value: WorkSessionStatus.ACTIVE, label: "Active" },
{ value: WorkSessionStatus.COMPLETED, label: "Completed" },
{ value: WorkSessionStatus.ABANDONED, label: "Abandoned" },
const statuses: { value: WorkSessionStatus; label: string; hint: string }[] = [
{
value: WorkSessionStatus.ACTIVE,
label: "Active",
hint: "An agent is currently working this branch.",
},
{
value: WorkSessionStatus.COMPLETED,
label: "Completed",
hint: "The session's PR was merged — the branch's work is done.",
},
{
value: WorkSessionStatus.ABANDONED,
label: "Abandoned",
hint: "Superseded by a re-claim, cancellation, or project deletion — never an auto-timeout.",
},
];
export function WorkSessionFilters({
@@ -51,29 +64,36 @@ export function WorkSessionFilters({
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Search */}
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by branch name..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
<HelpTip label="Filters the table client-side by branch name substring — doesn't re-query the server.">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by branch name..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
</HelpTip>
{/* Status Filter */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Status
{statusFilter.length > 0 && (
<Badge variant="secondary" className="ml-1">
{statusFilter.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
{/* DropdownMenuTrigger's asChild is safe to wrap directly (unlike
TabsTrigger/CollapsibleTrigger) it carries no persistent visual
data-state of its own. */}
<HelpTip label="Show only sessions in the checked status(es). No selection shows every session.">
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
<Filter className="h-4 w-4" />
Status
{statusFilter.length > 0 && (
<Badge variant="secondary" className="ml-1">
{statusFilter.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
</HelpTip>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>Filter by Status</DropdownMenuLabel>
<DropdownMenuSeparator />
@@ -83,7 +103,9 @@ export function WorkSessionFilters({
checked={statusFilter.includes(status.value)}
onCheckedChange={() => handleStatusToggle(status.value)}
>
{status.label}
<HelpTip label={status.hint}>
<span className="w-fit">{status.label}</span>
</HelpTip>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
@@ -91,10 +113,12 @@ export function WorkSessionFilters({
{/* Clear Filters */}
{hasActiveFilters && (
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Clear
</Button>
<HelpTip label="Resets the search text and status filter — shows every work session again.">
<Button variant="ghost" onClick={clearFilters} className="gap-2">
<X className="h-4 w-4" />
Clear
</Button>
</HelpTip>
)}
</div>
);
@@ -24,6 +24,7 @@ import {
ResponsiveTableCardRow,
} from "@/components/ui/responsive-table";
import { Skeleton } from "@/components/ui/skeleton";
import { HelpTip } from "@/components/ui/help-tip";
import {
GitBranch,
GitPullRequest,
@@ -40,6 +41,15 @@ interface WorkSessionTableProps {
isLoading: boolean;
}
// Mirrors the status hints in work-session-filters.tsx's dropdown — grounded
// in roboco/services/work_session.py's transition triggers.
const STATUS_HINTS: Record<WorkSessionStatus, string> = {
active: "An agent is currently working this branch.",
completed: "The session's PR was merged — the branch's work is done.",
abandoned:
"Superseded by a re-claim, cancellation, or project deletion — never an auto-timeout.",
};
function getStatusBadge(status: WorkSessionStatus) {
switch (status) {
case "active":
@@ -68,6 +78,29 @@ function getStatusBadge(status: WorkSessionStatus) {
}
}
function StatusBadge({ status }: { status: WorkSessionStatus }) {
return (
<HelpTip label={STATUS_HINTS[status]}>
<span className="w-fit">{getStatusBadge(status)}</span>
</HelpTip>
);
}
function PrBadge({ hasPr }: { hasPr: boolean }) {
return hasPr ? (
<HelpTip label="This session's branch has an open pull request on GitHub.">
<Badge className="bg-purple-500/10 text-purple-500 w-fit">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
</HelpTip>
) : (
<HelpTip label="No pull request opened yet for this session's branch.">
<span className="text-sm text-muted-foreground w-fit">No PR</span>
</HelpTip>
);
}
export function WorkSessionTable({
sessions,
isLoading,
@@ -115,42 +148,44 @@ export function WorkSessionTable({
<TableCell>
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="font-medium hover:underline font-mono text-sm"
>
{session.branch_name}
</Link>
<HelpTip label="Open this session's detail page.">
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="font-medium hover:underline font-mono text-sm"
>
{session.branch_name}
</Link>
</HelpTip>
</div>
</TableCell>
<TableCell>
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
title={session.task_id}
>
{session.task_id.slice(0, 8)}...
</Link>
<HelpTip label={`Open task ${session.task_id}`}>
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</HelpTip>
</TableCell>
<TableCell>{getStatusBadge(session.status)}</TableCell>
<TableCell>
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-sm text-muted-foreground">
No PR
</span>
)}
<StatusBadge status={session.status} />
</TableCell>
<TableCell>
<PrBadge hasPr={session.has_pr} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
<HelpTip
label={new Date(session.started_at).toLocaleString()}
>
<span className="w-fit">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</TableCell>
<TableCell>
<Tooltip>
@@ -180,14 +215,17 @@ export function WorkSessionTable({
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" />
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="truncate font-mono text-sm font-medium hover:underline"
title={session.branch_name}
<HelpTip
label={`Open this session's detail page — ${session.branch_name}`}
>
{session.branch_name}
</Link>
<Link
prefetch={false}
href={`/work-sessions/${session.id}`}
className="truncate font-mono text-sm font-medium hover:underline"
>
{session.branch_name}
</Link>
</HelpTip>
</div>
<Tooltip>
<TooltipTrigger asChild>
@@ -205,32 +243,32 @@ export function WorkSessionTable({
</div>
<div className="mt-3 divide-y">
<ResponsiveTableCardRow label="Task">
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
title={session.task_id}
>
{session.task_id.slice(0, 8)}...
</Link>
<HelpTip label={`Open task ${session.task_id}`}>
<Link
prefetch={false}
href={`/tasks/${session.task_id}`}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{session.task_id.slice(0, 8)}...
</Link>
</HelpTip>
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Status">
{getStatusBadge(session.status)}
<StatusBadge status={session.status} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="PR">
{session.has_pr ? (
<Badge className="bg-purple-500/10 text-purple-500">
<GitPullRequest className="h-3 w-3 mr-1" />
PR Open
</Badge>
) : (
<span className="text-muted-foreground">No PR</span>
)}
<PrBadge hasPr={session.has_pr} />
</ResponsiveTableCardRow>
<ResponsiveTableCardRow label="Started">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
<HelpTip
label={new Date(session.started_at).toLocaleString()}
>
<span className="w-fit">
{formatDistanceToNow(new Date(session.started_at), {
addSuffix: true,
})}
</span>
</HelpTip>
</ResponsiveTableCardRow>
</div>
</ResponsiveTableCard>