mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 & 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 & Head of Marketing)</Label>
|
||||
<HelpTip label="Fetched live only while this dialog is open — read it before approving.">
|
||||
<Label>Board review (Product Owner & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
source→target 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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user