Files
kandev/apps/web/components/task/task-top-bar.tsx
T
João Salavisa 83e01477df refactor: extract shared shapes between jira and linear integrations (#759)
* refactor: extract shared shapes between jira and linear integrations

Factors out the duplicated logic between internal/jira + internal/linear
(backend) and components/jira + components/linear (frontend) into reusable
shared shapes. Follow-up to PR #736.

Backend:
- New internal/integrations/secretadapter wraps secrets.SecretStore with
  the upsert API both jira.SecretStore and linear.SecretStore expect;
  drops both adapter copies from cmd/kandev/services.go.
- New internal/integrations/healthpoll owns the 90s auth-health loop via
  a Prober interface; linear/poller.go is a thin wrapper, jira/poller.go
  keeps only its (jira-specific) issue-watch loop.
- provideServices split into initGitHubService / initJiraService /
  initLinearService — now 75 lines (was ~125, exceeded the 80-line limit).

Frontend:
- New hooks/domains/integrations/use-integration-availability.ts and
  use-integration-enabled.ts; jira/linear hooks reduce to one-line
  delegates and move from components/<x>/ to hooks/domains/<x>/ per the
  project convention Greptile flagged on PR #736.
- New components/integrations/{auth-status-banner,auth-error-message,
  validated-popover}.tsx; the four link/import-bar wrappers drop from
  ~110 lines to ~35 each, and JiraLinkButton picks up the
  clear-error-on-close behaviour LinearLinkButton already had.

Docs:
- New 'Adding a new third-party integration' section in CLAUDE.md/AGENTS.md
  documenting the shared shapes and intentional Jira/Linear divergence
  (transition vs state, JQL vs filter object, eventBus only on jira).

* fix: address PR #759 review feedback

- validated-popover: outline-with-label trigger now sets type="button" and
  honours triggerDisabled, matching ghost-icon (Greptile, Claude, CodeRabbit).
- jira-link-button / linear-link-button: move updateTask() into the awaited
  fetch path so a failed title update surfaces as the popover's inline error
  instead of an unhandled rejection (CodeRabbit).
- use-integration-availability: monotonic requestId race guard prevents a
  slow earlier probe from overwriting a newer auth result (CodeRabbit).
- use-integration-enabled: drop unnecessary async on synchronous init
  (Claude).
- jira/linear poller smoke tests: wrap in testing/synctest instead of
  time.After(2s) per backend testing convention (CodeRabbit nitpick).

Skipped with reasoning on the threads:
- secretadapter Set TOCTOU — single-process SQLite + single-writer per
  workspace; constraint violation on conflict surfaces as an error to the
  caller, not silent corruption.
- healthpoll SetInterval validation — the function isn't called from
  production code today; a panic on misuse would be a footgun and silent
  ignore would just hide bugs differently.

* fix: address PR #759 round-2 review feedback

- jira-settings / linear-settings: drop the local STATUS_REFRESH_MS = 90_000
  copies and import the shared INTEGRATION_STATUS_REFRESH_MS so changing the
  cadence only touches one file (Claude).
- healthpoll: rename Poller.ProbeAll → probeAll. The only callers are the
  loop and the in-package tests, so widening the public API wasn't buying
  anything (Claude).

* fix: invalidate ValidatedPopover in-flight submits on close

If the user clicked Submit and dismissed the popover before the request
settled, the next time they re-opened it would inherit `loading=true` (the
disabled-while-loading submit button was stuck) and a late rejection from
the previous session could repopulate `error` in the new one.

Track the submit via a `useRef` token, bump it on close, and ignore any
result/rejection whose token no longer matches. Also reset `loading` and
`error` on close so the next open starts clean. Adds a regression test
covering both the late-resolution and late-rejection paths.

Addresses CodeRabbit review on PR #759.

* ci: trigger workflow re-run

GitHub Actions did not dispatch the pull_request workflows on the previous
two pushes (1d869f0e, 0ab6c034); only pull_request_target and CodeQL fired.
Empty commit to nudge synchronize event.

* fix: address PR #759 round-3 review feedback

- jira.Poller.Stop: add nil-receiver guard so callers using the
  defer-Stop-after-NewPoller pattern don't panic when initJiraService
  returned nil (Greptile + Claude). Matches linear.Poller's guard.
- healthpoll TestStart_RunsImmediateProbe + TestStart_IsIdempotent:
  rewrap in testing/synctest with synctest.Wait() instead of
  time.After(2s) wall-clock waits. Drops the now-unused probedAll signal
  channel from fakeProber (Claude).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-02 11:50:19 +01:00

561 lines
17 KiB
TypeScript

"use client";
import { memo, useCallback, type ReactNode } from "react";
import Link from "next/link";
import { IconBug, IconDots, IconHome, IconSettings } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
import { EditorsMenu } from "@/components/task/editors-menu";
import { BranchPathPopover } from "@/components/task/branch-path-popover";
import { LayoutPresetSelector } from "@/components/task/layout-preset-selector";
import { DocumentControls } from "@/components/task/document/document-controls";
import { VcsSplitButton } from "@/components/vcs-split-button";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { JiraTicketButton, extractJiraKey } from "@/components/jira/jira-ticket-button";
import { JiraLinkButton } from "@/components/jira/jira-link-button";
import { LinearIssueButton, extractLinearKey } from "@/components/linear/linear-issue-button";
import { LinearLinkButton } from "@/components/linear/linear-link-button";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { QuickChatButton } from "@/components/task/quick-chat-button";
import {
TopbarActionOverflow,
type TopbarOverflowItem,
} from "@/components/task/topbar-action-overflow";
import { IntegrationsMenu } from "@/components/integrations/integrations-menu";
import { DEBUG_UI } from "@/lib/config";
import { toast } from "sonner";
type TaskTopBarProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
baseBranch?: string;
onStartAgent?: (agentProfileId: string) => void;
onStopAgent?: () => void;
isAgentRunning?: boolean;
isAgentLoading?: boolean;
worktreePath?: string | null;
worktreeBranch?: string | null;
repositoryPath?: string | null;
repositoryName?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
workflowSteps?: WorkflowStepperStep[];
currentStepId?: string | null;
workflowId?: string | null;
workspaceId?: string | null;
isArchived?: boolean;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
remoteExecutorName?: string | null;
remoteExecutorType?: string | null;
remoteState?: string | null;
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
};
type TopBarLeftProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
repositoryName?: string | null;
displayBranch?: string;
repositoryPath?: string | null;
worktreePath?: string | null;
isRemoteExecutor?: boolean;
remoteExecutorName?: string | null;
remoteExecutorType?: string | null;
remoteState?: string | null;
remoteCreatedAt?: string | null;
remoteCheckedAt?: string | null;
remoteStatusError?: string | null;
workspaceId?: string | null;
onRenameBranch?: (newName: string) => Promise<void>;
};
const TaskTopBar = memo(function TaskTopBar({
taskId,
activeSessionId,
taskTitle,
baseBranch,
worktreePath,
worktreeBranch,
repositoryPath,
repositoryName,
showDebugOverlay,
onToggleDebugOverlay,
workflowSteps,
currentStepId,
workflowId,
workspaceId,
isArchived,
isRemoteExecutor,
isAgentctlReady,
remoteExecutorName,
remoteExecutorType,
remoteState,
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
}: TaskTopBarProps) {
const git = useSessionGit(activeSessionId);
// Prefer live git status branch (updates after rename), fallback to session worktree branch
const displayBranch = git.branch || worktreeBranch || baseBranch;
// Callback for renaming branch
const handleRenameBranch = useCallback(
async (newName: string) => {
const result = await git.renameBranch(newName);
if (result.success) {
toast.success(`Branch renamed to "${newName}"`);
} else {
const msg = result.error || "Failed to rename branch";
toast.error(msg);
throw new Error(msg);
}
},
[git],
);
return (
<header
data-testid="task-topbar"
className="@container/topbar grid grid-cols-[minmax(0,1fr)_minmax(0,auto)_minmax(0,1fr)] items-center gap-2 overflow-hidden px-3 py-1 border-b border-border"
>
<TopBarLeft
taskId={taskId}
activeSessionId={activeSessionId}
taskTitle={taskTitle}
repositoryName={repositoryName}
displayBranch={displayBranch}
repositoryPath={repositoryPath}
worktreePath={worktreePath}
isRemoteExecutor={isRemoteExecutor}
remoteExecutorName={remoteExecutorName}
remoteExecutorType={remoteExecutorType}
remoteState={remoteState}
remoteCreatedAt={remoteCreatedAt}
remoteCheckedAt={remoteCheckedAt}
remoteStatusError={remoteStatusError}
workspaceId={workspaceId}
onRenameBranch={activeSessionId ? handleRenameBranch : undefined}
/>
<div className="min-w-0 justify-self-center overflow-hidden">
{workflowSteps && workflowSteps.length > 0 && (
<WorkflowStepper
steps={workflowSteps}
currentStepId={currentStepId ?? null}
taskId={taskId ?? null}
workflowId={workflowId ?? null}
isArchived={isArchived}
/>
)}
</div>
<TopBarRight
taskId={taskId}
activeSessionId={activeSessionId}
baseBranch={baseBranch}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
/>
</header>
);
});
// IssueTrackerButtons picks the right ticket button for a task. Jira and
// Linear use the same TEAM-NUMBER identifier shape, so both `extract` calls
// would match "ENG-123" — we resolve ambiguity by preferring whichever
// integration is currently available for the workspace, with Jira winning the
// tie-break since it shipped first. When the title carries no identifier,
// both link buttons are offered (each gated on its own availability).
function IssueTrackerButtons({
taskId,
workspaceId,
taskTitle,
}: {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | null | undefined;
}) {
const jiraAvailable = useJiraAvailable(workspaceId);
const linearAvailable = useLinearAvailable(workspaceId);
const jiraKey = extractJiraKey(taskTitle);
const linearKey = extractLinearKey(taskTitle);
if (jiraKey && jiraAvailable) {
return <JiraTicketButton workspaceId={workspaceId} taskTitle={taskTitle} />;
}
if (linearKey && linearAvailable) {
return <LinearIssueButton workspaceId={workspaceId} taskTitle={taskTitle} />;
}
return (
<>
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<LinearLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
);
}
/** Left section: breadcrumbs, branch pill */
function TopBarLeft({
taskId,
activeSessionId,
taskTitle,
repositoryName,
displayBranch,
repositoryPath,
worktreePath,
isRemoteExecutor,
remoteExecutorName,
remoteExecutorType,
remoteState,
remoteCreatedAt,
remoteCheckedAt,
remoteStatusError,
workspaceId,
onRenameBranch,
}: TopBarLeftProps) {
return (
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap text-sm min-w-0">
<BreadcrumbItem className="shrink-0">
<BreadcrumbLink asChild>
<Link
href="/"
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
>
<IconHome className="h-4 w-4" />
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{repositoryName && (
<>
<BreadcrumbSeparator className="shrink-0 @max-[900px]/topbar:hidden" />
<BreadcrumbItem className="min-w-0 @max-[900px]/topbar:hidden">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground truncate block">{repositoryName}</span>
</TooltipTrigger>
<TooltipContent>{repositoryName}</TooltipContent>
</Tooltip>
</BreadcrumbItem>
</>
)}
<BreadcrumbSeparator className="shrink-0" />
<BreadcrumbItem className="min-w-0">
<Tooltip>
<TooltipTrigger asChild>
<BreadcrumbPage className="font-medium truncate">
{taskTitle ?? "Task details"}
</BreadcrumbPage>
</TooltipTrigger>
<TooltipContent>{taskTitle ?? "Task details"}</TooltipContent>
</Tooltip>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<IntegrationsMenu workspaceId={workspaceId ?? undefined} />
<div className="shrink-0 @max-[1352px]/topbar:hidden">
<BranchPathPopover
displayBranch={displayBranch}
repositoryPath={repositoryPath}
worktreePath={worktreePath}
onRenameBranch={onRenameBranch}
/>
</div>
{isRemoteExecutor && (
<RemoteCloudTooltip
taskId={taskId ?? ""}
sessionId={activeSessionId}
fallbackName={remoteExecutorName ?? remoteExecutorType}
iconClassName="h-4 w-4"
status={{
remote_name: remoteExecutorName ?? undefined,
remote_state: remoteState ?? undefined,
remote_created_at: remoteCreatedAt ?? undefined,
remote_checked_at: remoteCheckedAt ?? undefined,
remote_status_error: remoteStatusError ?? undefined,
}}
/>
)}
</div>
);
}
function TopbarCluster({
label,
className = "",
children,
}: {
label: string;
className?: string;
children: ReactNode;
}) {
return (
<div
aria-label={label}
className={`inline-flex shrink-0 items-center gap-1 [&:empty]:hidden ${className}`}
>
{children}
</div>
);
}
function MoreToolsMenu({
showDebugOverlay,
onToggleDebugOverlay,
}: {
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
}) {
const showDebugItem = DEBUG_UI && onToggleDebugOverlay;
const debugLabel = showDebugOverlay ? "Hide Debug Info" : "Show Debug Info";
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 cursor-pointer px-2"
aria-label="More task tools"
>
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>More tools</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel className="text-xs">More tools</DropdownMenuLabel>
{showDebugItem && (
<>
<DropdownMenuItem className="cursor-pointer gap-2" onClick={onToggleDebugOverlay}>
<IconBug className="h-4 w-4 text-muted-foreground" />
<span>{debugLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem asChild className="cursor-pointer gap-2">
<Link href="/settings/general">
<IconSettings className="h-4 w-4 text-muted-foreground" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function SettingsButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button asChild size="sm" variant="outline" className="h-8 cursor-pointer px-2">
<Link href="/settings/general" aria-label="Settings">
<IconSettings className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
);
}
function AttentionStatusGroup({
taskId,
activeSessionId,
isArchived,
workspaceId,
isRemoteExecutor,
isAgentctlReady,
taskTitle,
}: {
taskId?: string | null;
activeSessionId?: string | null;
isArchived?: boolean;
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
taskTitle?: string;
}) {
return (
<TopbarCluster label="Task status and attention" className="[&_button]:h-8 [&_button]:text-xs">
<DocumentControls activeSessionId={activeSessionId ?? null} />
{!isArchived && (
<>
<PortForwardButton
isRemoteExecutor={isRemoteExecutor}
sessionId={activeSessionId}
isAgentctlReady={isAgentctlReady}
/>
<PRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
)}
</TopbarCluster>
);
}
function TopbarToolsGroup({
activeSessionId,
showDebugOverlay,
onToggleDebugOverlay,
isArchived,
}: {
activeSessionId?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
}) {
const showDebugMenu = DEBUG_UI && onToggleDebugOverlay;
return (
<TopbarCluster label="Task tools" className="[&_button]:h-8 [&_button]:text-xs">
{!isArchived && (
<>
<LayoutPresetSelector />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
</>
)}
{showDebugMenu ? (
<MoreToolsMenu
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
/>
) : (
<SettingsButton />
)}
</TopbarCluster>
);
}
/** Right section: primary VCS action, status/attention, tools menu */
function TopBarRight({
taskId,
activeSessionId,
baseBranch,
showDebugOverlay,
onToggleDebugOverlay,
isArchived,
workspaceId,
isRemoteExecutor,
isAgentctlReady,
taskTitle,
}: {
taskId?: string | null;
activeSessionId?: string | null;
baseBranch?: string;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
isArchived?: boolean;
workspaceId?: string | null;
isRemoteExecutor?: boolean;
isAgentctlReady?: boolean;
taskTitle?: string;
}) {
const items: TopbarOverflowItem[] = [];
if (!isArchived && workspaceId) {
items.push({
id: "quick-chat",
label: "Quick chat",
priority: 20,
content: (
<TopbarCluster label="Quick chat" className="[&_button]:h-8 [&_button]:text-xs">
<QuickChatButton workspaceId={workspaceId} />
</TopbarCluster>
),
});
}
if (!isArchived) {
items.push({
id: "vcs",
label: "Primary version control action",
priority: 100,
required: true,
content: (
<TopbarCluster label="Primary version control action">
<VcsSplitButton
sessionId={activeSessionId ?? null}
baseBranch={baseBranch}
buttonSize="lg"
/>
</TopbarCluster>
),
});
}
items.push({
id: "attention",
label: "Task status and attention",
priority: 80,
content: (
<AttentionStatusGroup
taskId={taskId}
activeSessionId={activeSessionId}
isArchived={isArchived}
workspaceId={workspaceId}
isRemoteExecutor={isRemoteExecutor}
isAgentctlReady={isAgentctlReady}
taskTitle={taskTitle}
/>
),
});
items.push({
id: "tools",
label: "Task tools",
priority: 10,
content: (
<TopbarToolsGroup
activeSessionId={activeSessionId}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
isArchived={isArchived}
/>
),
});
return (
<TopbarActionOverflow items={items} className="justify-self-end [&_button]:whitespace-nowrap" />
);
}
export { TaskTopBar };