54b98ed9eb
* feat(jira): add Jira integration for ticket browsing, import, and task linking Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings (site URL, email, API token or session cookie), encrypted secret storage, and a REST client for tickets, projects, search, and transitions. A background poller probes credentials on a 90s cadence and the keepalive runs immediately on save so the UI's connected indicator updates without delay. The /jira page provides ticket browsing with filter pills, custom JQL, saved views (including JQL), and configurable task-launch presets that prefill TaskCreateDialog. JiraLinkButton on the task top bar links an existing task to a ticket by prepending its key to the title; the ticket popover surfaces details, transitions, and re-auth prompts on session expiry. Topbar shows separate icon buttons for GitHub and Jira when each is configured. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address PR review feedback - Token-based pagination (pageToken/isLast) replaces broken offset/total pagination - Race-fix in service.clientFor: re-check cache after acquiring write lock - sync.Mutex guards poller started/cancel state against concurrent Start/Stop - Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test - Async auth probe on SetConfig with detached context.Background goroutine - Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel - Propagate 4xx APIErrors from ListTransitions in GetTicket - Distinguish "not found" from real errors in jiraSecretAdapter.Exists - Log secret Exists errors instead of swallowing - walkADF: handle softBreak (was only hardBreak despite the doc-comment) - Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates - ticket-row imports formatRelative/statusBadgeClass from common - isFilterState type guard validates SavedView.filters shape - task-presets readStorage preserves user's "cleared all" intent (returns []) - task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits - jira-api: caller `init` no longer overrides method/body via spread order - jira-settings: disable Test button when no saved/typed secret Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): record auth probe failure even when probe ctx expires - RecordAuthHealth now uses a fresh context.Background-derived ctx with a 5s timeout for the UpdateAuthHealth DB write, so a probe that consumes its full 15s deadline still persists the failure (greptile P1). - SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth manages its own probe timeout, so context.Background is enough. - Add Service.SetProbeHook so tests can synchronize on probe completion; service_test waitForAuthProbe now blocks on the hook channel instead of time.Sleep polling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): address coderabbit review feedback (round 3) - url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths to harden against malicious keys ('../../foo', 'KEY?override=x'). - factoryHit on the test fixture is now sync/atomic.Int32; the async probe goroutine spawned by SetConfig races the test thread under -race. - GetConfig logs Reveal failures (was silently dropped, leaving the UI to show "no expiry info" indistinguishably from a freshly-saved cookie). - DeleteConfig logs secret-delete errors instead of swallowing them. - clientFor distinguishes a secret-store failure from "not configured" so the UI doesn't render the configure-CTA for transient backend errors. - Unify ErrNotConfigured wire code: HTTP and WebSocket both return "JIRA_NOT_CONFIGURED" via a shared constant. - JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so re-linking a task replaces the prefix instead of stacking it. - jira-settings clears testResult on save/delete so the alert reflects only the currently-saved credentials. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover Reveal error path in clientFor Reveal-error case is distinct from "no config row" — the former wraps the underlying error, the latter returns ErrNotConfigured. Without this test the changed contract has no regression coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): route secret adapter Set through Exists Treating every Get error as "not found" turned a transient DB error on an existing row into a constraint-violation Create that masked the real cause. Reuse Exists so the "secret not found:" prefix is the only path that falls through to Create. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(jira): cover jiraSecretAdapter.Set branching Routing Set through Exists changed the contract: a transient DB error in Get now propagates instead of cascading into a constraint-violation Create. Add three tests so a future refactor cannot regress this without CI catching it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(jira): split Reveal errors and guard Save for new configs - resolveCredentials now mirrors the clientFor split: a Reveal error surfaces as `read jira secret: %w` with a warn log instead of collapsing into "no token stored — paste one to test". - disableSave gains the same missing-secret guard as disableTest so a brand-new config can no longer POST an empty secret and leave the workspace stuck with hasSecret=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback } from "react";
|
|
import Link from "next/link";
|
|
import { IconBug, IconHome, IconSettings } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
} from "@kandev/ui/breadcrumb";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { CommitStatBadge } from "@/components/diff-stat";
|
|
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 { 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 { 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;
|
|
};
|
|
|
|
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 className="@container/topbar grid grid-cols-[1fr_auto_1fr] items-center 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}
|
|
onRenameBranch={activeSessionId ? handleRenameBranch : undefined}
|
|
/>
|
|
{workflowSteps && workflowSteps.length > 0 && (
|
|
<WorkflowStepper
|
|
steps={workflowSteps}
|
|
currentStepId={currentStepId ?? null}
|
|
taskId={taskId ?? null}
|
|
workflowId={workflowId ?? null}
|
|
isArchived={isArchived}
|
|
/>
|
|
)}
|
|
<TopBarRight
|
|
taskId={taskId}
|
|
activeSessionId={activeSessionId}
|
|
baseBranch={baseBranch}
|
|
gitStatus={git}
|
|
showDebugOverlay={showDebugOverlay}
|
|
onToggleDebugOverlay={onToggleDebugOverlay}
|
|
isArchived={isArchived}
|
|
workspaceId={workspaceId}
|
|
isRemoteExecutor={isRemoteExecutor}
|
|
isAgentctlReady={isAgentctlReady}
|
|
taskTitle={taskTitle}
|
|
/>
|
|
</header>
|
|
);
|
|
});
|
|
|
|
/** Left section: breadcrumbs, branch pill */
|
|
function TopBarLeft({
|
|
taskId,
|
|
activeSessionId,
|
|
taskTitle,
|
|
repositoryName,
|
|
displayBranch,
|
|
repositoryPath,
|
|
worktreePath,
|
|
isRemoteExecutor,
|
|
remoteExecutorName,
|
|
remoteExecutorType,
|
|
remoteState,
|
|
remoteCreatedAt,
|
|
remoteCheckedAt,
|
|
remoteStatusError,
|
|
onRenameBranch,
|
|
}: {
|
|
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;
|
|
onRenameBranch?: (newName: string) => Promise<void>;
|
|
}) {
|
|
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>
|
|
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/** Ahead/Behind commit status badges */
|
|
function GitAheadBehindBadges({
|
|
gitStatus,
|
|
baseBranch,
|
|
}: {
|
|
gitStatus: { ahead: number; behind: number };
|
|
baseBranch?: string;
|
|
}) {
|
|
const ahead = gitStatus?.ahead ?? 0;
|
|
const behind = gitStatus?.behind ?? 0;
|
|
if (ahead === 0 && behind === 0) return null;
|
|
const compareRef = baseBranch || "main";
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{ahead > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="cursor-default">
|
|
<CommitStatBadge label={`${ahead} ahead`} tone="ahead" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{ahead} commit{ahead !== 1 ? "s" : ""} ahead of {compareRef}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{behind > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="cursor-default">
|
|
<CommitStatBadge label={`${behind} behind`} tone="behind" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{behind} commit{behind !== 1 ? "s" : ""} behind {compareRef}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Right section: git badges, debug toggle, document controls, editors, VCS, settings */
|
|
function TopBarRight({
|
|
taskId,
|
|
activeSessionId,
|
|
baseBranch,
|
|
gitStatus,
|
|
showDebugOverlay,
|
|
onToggleDebugOverlay,
|
|
isArchived,
|
|
workspaceId,
|
|
isRemoteExecutor,
|
|
isAgentctlReady,
|
|
taskTitle,
|
|
}: {
|
|
taskId?: string | null;
|
|
activeSessionId?: string | null;
|
|
baseBranch?: string;
|
|
gitStatus: { ahead: number; behind: number };
|
|
showDebugOverlay?: boolean;
|
|
onToggleDebugOverlay?: () => void;
|
|
isArchived?: boolean;
|
|
workspaceId?: string | null;
|
|
isRemoteExecutor?: boolean;
|
|
isAgentctlReady?: boolean;
|
|
taskTitle?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2 justify-end">
|
|
{DEBUG_UI && onToggleDebugOverlay && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant={showDebugOverlay ? "default" : "outline"}
|
|
className="cursor-pointer px-2"
|
|
onClick={onToggleDebugOverlay}
|
|
>
|
|
<IconBug className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{showDebugOverlay ? "Hide Debug Info" : "Show Debug Info"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<GitAheadBehindBadges gitStatus={gitStatus} baseBranch={baseBranch} />
|
|
<DocumentControls activeSessionId={activeSessionId ?? null} />
|
|
{!isArchived && (
|
|
<>
|
|
<PortForwardButton
|
|
isRemoteExecutor={isRemoteExecutor}
|
|
sessionId={activeSessionId}
|
|
isAgentctlReady={isAgentctlReady}
|
|
/>
|
|
<PRTopbarButton />
|
|
{extractJiraKey(taskTitle) ? (
|
|
<JiraTicketButton workspaceId={workspaceId} taskTitle={taskTitle} />
|
|
) : (
|
|
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
|
|
)}
|
|
<QuickChatButton workspaceId={workspaceId} />
|
|
<VcsSplitButton sessionId={activeSessionId ?? null} baseBranch={baseBranch} />
|
|
<LayoutPresetSelector />
|
|
<EditorsMenu activeSessionId={activeSessionId ?? null} />
|
|
</>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="outline" className="cursor-pointer px-2" asChild>
|
|
<Link href="/settings/general">
|
|
<IconSettings className="h-4 w-4" />
|
|
</Link>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Settings</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export { TaskTopBar };
|