feat: move integration links into task menus (#1644)

* feat: move integration links into task menus

* fix: address integration link review feedback

* fix: preserve archived task title when linking

* fix: address integration link review issues

* fix: remove ignored swimlane availability prop
This commit is contained in:
José Almeida
2026-07-09 23:43:35 +01:00
committed by GitHub
parent 500068c961
commit 4eae00964d
30 changed files with 1252 additions and 441 deletions
@@ -18,9 +18,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
// Each integration provides {icon, label, tooltip, placeholder, regex,
// fetch, onSuccess, validationHint, headline, submitLabel, submittingLabel}.
//
// Designing this as one shared skeleton both fixes the existing bug that
// JiraLinkButton was missing the clear-error-on-close behaviour LinearLinkButton
// had, and prevents future drift between the four near-identical popovers.
// Designing this as one shared skeleton prevents drift between the
// near-identical integration popovers.
export type ValidatedPopoverTriggerStyle = "outline-with-label" | "ghost-icon";
@@ -1,63 +0,0 @@
"use client";
import { useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { toast } from "sonner";
import { getJiraTicket } from "@/lib/api/domains/jira-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { JIRA_KEY_RE } from "./jira-ticket-common";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type JiraLinkButtonProps = {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | undefined | null;
};
// JiraLinkButton lets the user attach a Jira ticket to an existing task by
// prepending the ticket key to its title ("PROJ-123: ..."). The existing
// JiraTicketButton picks up the key automatically once the title is updated.
export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) {
const available = useJiraAvailable(workspaceId);
const buildLinkedTitle = useCallback(
(key: string) => {
// Strip an existing leading "PROJ-123: " so re-linking a task to a
// different ticket replaces the prefix instead of stacking
// ("PROJ-456: PROJ-123: ...").
const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]+-\d+:\s*/, "");
return stripped ? `${key}: ${stripped}` : key;
},
[taskTitle],
);
if (!available || !taskId || !workspaceId) return null;
return (
<ValidatedPopover
triggerStyle="outline-with-label"
triggerIcon={<IconLink className="h-4 w-4" />}
triggerLabel="Link Jira"
tooltip="Link this task to a Jira ticket"
headline="Link to Jira ticket"
placeholder="PROJ-123 or paste ticket URL"
extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null}
validationHint="Paste a Jira ticket URL or key (PROJ-123)"
// Run getJiraTicket *and* the title update inside the awaited fetch so
// a failure on either surfaces as the popover's inline error instead of
// an unhandled rejection that leaves the user thinking the link
// succeeded.
fetch={async (key) => {
const ticket = await getJiraTicket(key, { workspaceId });
await updateTask(taskId, { title: buildLinkedTitle(key) });
return ticket;
}}
onSuccess={(key) => {
toast.success(`Linked to ${key}`);
}}
submitLabel="Link"
submittingLabel="Linking..."
/>
);
}
+16 -1
View File
@@ -22,6 +22,10 @@ import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStore } from "@/components/state-provider";
import { getKanbanColumnGridTemplate } from "./kanban/kanban-grid-template";
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
import {
type KanbanExternalLinkAvailability,
useKanbanExternalLinkAvailability,
} from "./kanban-external-link-availability";
export type KanbanBoardGridProps = {
steps: WorkflowStep[];
@@ -56,7 +60,9 @@ type ColumnGridProps = Pick<
| "showMaximizeButton"
| "deletingTaskId"
| "archivingTaskId"
>;
> & {
externalLinkAvailability: KanbanExternalLinkAvailability;
};
function getTasksForStep(tasks: Task[], stepId: string) {
return tasks
@@ -92,6 +98,7 @@ function MobileLayout({
activeColumnIndex,
setActiveColumnIndex,
currentStepId,
externalLinkAvailability,
}: ColumnGridProps & {
showLoading: boolean;
activeTask: Task | null;
@@ -128,6 +135,7 @@ function MobileLayout({
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
externalLinkAvailability={externalLinkAvailability}
/>
<MobileDropTargets
steps={steps}
@@ -161,6 +169,7 @@ function TabletLayout({
archivingTaskId,
showLoading,
activeTask,
externalLinkAvailability,
}: ColumnGridProps & { showLoading: boolean; activeTask: Task | null }) {
return (
<>
@@ -184,6 +193,7 @@ function TabletLayout({
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
externalLinkAvailability={externalLinkAvailability}
/>
</div>
))}
@@ -212,6 +222,7 @@ function DesktopLayout({
showLoading,
activeTask,
isCompactDesktop,
externalLinkAvailability,
}: ColumnGridProps & {
showLoading: boolean;
activeTask: Task | null;
@@ -245,6 +256,7 @@ function DesktopLayout({
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
externalLinkAvailability={externalLinkAvailability}
/>
))}
</div>
@@ -306,6 +318,8 @@ export function KanbanBoardGrid({
const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint();
const activeColumnIndex = useAppStore((state) => state.mobileKanban.activeColumnIndex);
const setActiveColumnIndex = useAppStore((state) => state.setMobileKanbanColumnIndex);
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId);
const sensors = useKanbanBoardSensors();
// Calculate task counts per step for tabs
@@ -347,6 +361,7 @@ export function KanbanBoardGrid({
showMaximizeButton,
deletingTaskId,
archivingTaskId,
externalLinkAvailability,
};
let layoutContent: React.ReactNode;
@@ -1,7 +1,11 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry } from "./kanban-card-menu-items";
import {
buildKanbanCardMenuEntries,
KanbanCardDropdownMenuItems,
type KanbanCardMenuEntry,
} from "./kanban-card-menu-items";
// Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog.
describe("KanbanCardDropdownMenuItems — click propagation", () => {
@@ -81,3 +85,48 @@ describe("KanbanCardDropdownMenuItems — click propagation", () => {
expect(parentOnPointerDown).not.toHaveBeenCalled();
});
});
describe("buildKanbanCardMenuEntries — external issue links", () => {
function itemLabels(entry: KanbanCardMenuEntry | undefined) {
if (entry?.kind !== "submenu") return [];
return entry.children.filter((child) => child.kind === "item").map((child) => child.label);
}
it("adds configured external issue providers to the Link submenu", () => {
const entries = buildKanbanCardMenuEntries({
workflows: [],
stepsByWorkflowId: {},
onLinkPullRequest: vi.fn(),
onLinkIssue: vi.fn(),
onLinkJiraTicket: vi.fn(),
onLinkLinearIssue: vi.fn(),
onLinkSentryIssue: vi.fn(),
});
const linkMenu = entries.find((entry) => entry.kind === "submenu" && entry.key === "link");
expect(linkMenu?.kind).toBe("submenu");
expect(itemLabels(linkMenu)).toEqual([
"GitHub Pull Request",
"GitHub Issue",
"Jira Ticket",
"Linear Issue",
"Sentry Issue",
]);
});
it("omits external issue providers that are not configured", () => {
const entries = buildKanbanCardMenuEntries({
workflows: [],
stepsByWorkflowId: {},
onLinkPullRequest: vi.fn(),
onLinkIssue: vi.fn(),
onLinkJiraTicket: vi.fn(),
});
const linkMenu = entries.find((entry) => entry.kind === "submenu" && entry.key === "link");
expect(linkMenu?.kind).toBe("submenu");
expect(itemLabels(linkMenu)).toEqual(["GitHub Pull Request", "GitHub Issue", "Jira Ticket"]);
});
});
+83 -21
View File
@@ -4,12 +4,14 @@ import { useMemo, type ReactNode } from "react";
import {
IconArchive,
IconArrowRight,
IconBrandSentry,
IconCircleDot,
IconGitPullRequest,
IconLink,
IconLoader,
IconLogicBuffer,
IconPencil,
IconTicket,
IconTrash,
} from "@tabler/icons-react";
import {
@@ -83,6 +85,9 @@ type BuildKanbanCardMenuEntriesArgs = {
onDelete?: () => void;
onLinkPullRequest?: () => void;
onLinkIssue?: () => void;
onLinkJiraTicket?: () => void;
onLinkLinearIssue?: () => void;
onLinkSentryIssue?: () => void;
onMoveToStep?: (stepId: string) => void;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
};
@@ -222,12 +227,82 @@ function buildLinkSubmenu({
disabled,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
}: {
disabled?: boolean;
onLinkPullRequest?: () => void;
onLinkIssue?: () => void;
onLinkJiraTicket?: () => void;
onLinkLinearIssue?: () => void;
onLinkSentryIssue?: () => void;
}): KanbanCardMenuEntry | null {
if (!onLinkPullRequest && !onLinkIssue) return null;
if (
!onLinkPullRequest &&
!onLinkIssue &&
!onLinkJiraTicket &&
!onLinkLinearIssue &&
!onLinkSentryIssue
) {
return null;
}
const children: KanbanCardMenuEntry[] = [];
if (onLinkPullRequest) {
children.push({
kind: "item",
key: "link-github-pull-request",
testId: "task-context-link-github-pull-request",
icon: <IconGitPullRequest className="mr-2 h-4 w-4" />,
label: "GitHub Pull Request",
disabled,
onSelect: onLinkPullRequest,
});
}
if (onLinkIssue) {
children.push({
kind: "item",
key: "link-github-issue",
testId: "task-context-link-github-issue",
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
label: "GitHub Issue",
disabled,
onSelect: onLinkIssue,
});
}
if (onLinkJiraTicket) {
children.push({
kind: "item",
key: "link-jira-ticket",
testId: "task-context-link-jira-ticket",
icon: <IconTicket className="mr-2 h-4 w-4" />,
label: "Jira Ticket",
disabled,
onSelect: onLinkJiraTicket,
});
}
if (onLinkLinearIssue) {
children.push({
kind: "item",
key: "link-linear-issue",
testId: "task-context-link-linear-issue",
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
label: "Linear Issue",
disabled,
onSelect: onLinkLinearIssue,
});
}
if (onLinkSentryIssue) {
children.push({
kind: "item",
key: "link-sentry-issue",
testId: "task-context-link-sentry-issue",
icon: <IconBrandSentry className="mr-2 h-4 w-4" />,
label: "Sentry Issue",
disabled,
onSelect: onLinkSentryIssue,
});
}
return {
kind: "submenu",
key: "link",
@@ -236,26 +311,7 @@ function buildLinkSubmenu({
label: "Link",
disabled,
className: "w-56",
children: [
{
kind: "item",
key: "link-github-pull-request",
testId: "task-context-link-github-pull-request",
icon: <IconGitPullRequest className="mr-2 h-4 w-4" />,
label: "GitHub Pull Request",
disabled: disabled || !onLinkPullRequest,
onSelect: onLinkPullRequest,
},
{
kind: "item",
key: "link-github-issue",
testId: "task-context-link-github-issue",
icon: <IconCircleDot className="mr-2 h-4 w-4" />,
label: "GitHub Issue",
disabled: disabled || !onLinkIssue,
onSelect: onLinkIssue,
},
],
children,
};
}
@@ -272,6 +328,9 @@ export function buildKanbanCardMenuEntries({
onDelete,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
onMoveToStep,
onSendToWorkflow,
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
@@ -310,6 +369,9 @@ export function buildKanbanCardMenuEntries({
disabled: isProcessing,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
});
if (linkEntry) entries.push(linkEntry);
+185 -113
View File
@@ -11,6 +11,11 @@ import {
import { useAppStore } from "@/components/state-provider";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import {
TaskExternalLinkDialog,
type ExternalLinkProvider,
} from "@/components/task/task-external-link-dialog";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
@@ -68,6 +73,8 @@ export interface WorkflowStep {
interface KanbanCardProps {
task: Task;
workspaceId: string | null;
externalLinkAvailability: KanbanExternalLinkAvailability;
/** Display labels and hover paths of every repository linked to the task, primary first. */
repositoryChips?: RepositoryChip[];
onClick?: (task: Task) => void;
@@ -88,45 +95,22 @@ interface KanbanCardProps {
isMultiSelectMode?: boolean;
}
function useKanbanCardMenus({
function useKanbanCardMoveMenuActions({
task,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
}: Pick<
KanbanCardProps,
| "task"
| "steps"
| "isDeleting"
| "isArchiving"
| "isSelected"
| "selectedIds"
| "onEdit"
| "onDelete"
| "onArchive"
| "onMove"
>) {
}: Pick<KanbanCardProps, "task" | "steps" | "isSelected" | "selectedIds" | "onMove">) {
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const [showPRDialog, setShowPRDialog] = useState(false);
const [showIssueDialog, setShowIssueDialog] = useState(false);
const disabled = Boolean(isDeleting || isArchiving);
const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => {
void moveTasks(taskIds, workflowId, stepId).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
};
const moveToStepFromDropdown = (stepId: string) => {
if (onMove) {
onMove(task, stepId);
@@ -136,14 +120,8 @@ function useKanbanCardMenus({
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId);
}
};
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
// Sort into board order lazily (only when a move actually fires) so a backward
// range selection isn't scrambled — and we don't pay it on every card render.
const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds);
// A selection spanning workflows can't safely use same-workflow "Move to" (it
// would drag other-workflow cards into this card's workflow), so gate it — only
// computed for a genuine multi-selection to avoid per-render snapshot scans.
const isMixedWorkflowSelection =
selectedTaskIds.length > 1 &&
new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1;
@@ -156,11 +134,71 @@ function useKanbanCardMenus({
runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId);
};
return {
moveTargets,
moveToStepFromDropdown,
moveSelectedToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
sendTaskToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks([task.id], workflowId, stepId);
},
sendSelectionToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks(orderedSelectedIds(), workflowId, stepId);
},
};
}
function externalLinkHandlers(
availability: KanbanCardProps["externalLinkAvailability"],
setExternalLinkProvider: (provider: ExternalLinkProvider) => void,
) {
return {
onLinkJiraTicket: availability.jira ? () => setExternalLinkProvider("jira") : undefined,
onLinkLinearIssue: availability.linear ? () => setExternalLinkProvider("linear") : undefined,
onLinkSentryIssue: availability.sentry ? () => setExternalLinkProvider("sentry") : undefined,
};
}
function useKanbanCardMenus({
task,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
externalLinkAvailability,
}: Pick<
KanbanCardProps,
| "task"
| "externalLinkAvailability"
| "steps"
| "isDeleting"
| "isArchiving"
| "isSelected"
| "selectedIds"
| "onEdit"
| "onDelete"
| "onArchive"
| "onMove"
>) {
const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove });
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const [showPRDialog, setShowPRDialog] = useState(false);
const [showIssueDialog, setShowIssueDialog] = useState(false);
const [externalLinkProvider, setExternalLinkProvider] = useState<ExternalLinkProvider | null>(
null,
);
const disabled = Boolean(isDeleting || isArchiving);
const menuBase = {
currentWorkflowId: moveTargets.currentWorkflowId,
currentWorkflowId: moveMenu.moveTargets.currentWorkflowId,
currentStepId: task.workflowStepId,
workflows: moveTargets.workflowItems,
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
workflows: moveMenu.moveTargets.workflowItems,
stepsByWorkflowId: moveMenu.moveTargets.stepsByWorkflowId,
disabled,
isDeleting,
isArchiving,
@@ -169,24 +207,19 @@ function useKanbanCardMenus({
onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined,
onLinkPullRequest: () => setShowPRDialog(true),
onLinkIssue: () => setShowIssueDialog(true),
...externalLinkHandlers(externalLinkAvailability, setExternalLinkProvider),
};
return {
dropdownMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveToStepFromDropdown,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks([task.id], workflowId, stepId);
},
onMoveToStep: moveMenu.moveToStepFromDropdown,
onSendToWorkflow: moveMenu.sendTaskToWorkflow,
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
// Hide same-workflow "Move to" for a mixed-workflow selection; only the
// explicit "Send to workflow" path remains (matches the toolbar guard).
onMoveToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks(orderedSelectedIds(), workflowId, stepId);
},
onMoveToStep: moveMenu.moveSelectedToStep,
onSendToWorkflow: moveMenu.sendSelectionToWorkflow,
}),
showDeleteConfirm,
setShowDeleteConfirm,
@@ -196,35 +229,27 @@ function useKanbanCardMenus({
setShowPRDialog,
showIssueDialog,
setShowIssueDialog,
externalLinkProvider,
setExternalLinkProvider,
};
}
type KanbanCardMenuState = ReturnType<typeof useKanbanCardMenus>;
function KanbanCardDialogs({
task,
workspaceId,
repositories,
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
showPRDialog,
setShowPRDialog,
showIssueDialog,
setShowIssueDialog,
menu,
isDeleting,
isArchiving,
onDelete,
onArchive,
}: {
task: Task;
workspaceId: string | null;
repositories: Repository[];
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
showArchiveConfirm: boolean;
setShowArchiveConfirm: (open: boolean) => void;
showPRDialog: boolean;
setShowPRDialog: (open: boolean) => void;
showIssueDialog: boolean;
setShowIssueDialog: (open: boolean) => void;
menu: KanbanCardMenuState;
isDeleting?: boolean;
isArchiving?: boolean;
onDelete?: KanbanCardProps["onDelete"];
@@ -233,8 +258,8 @@ function KanbanCardDialogs({
return (
<>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
open={menu.showDeleteConfirm}
onOpenChange={menu.setShowDeleteConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
@@ -242,8 +267,8 @@ function KanbanCardDialogs({
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
open={menu.showArchiveConfirm}
onOpenChange={menu.setShowArchiveConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
@@ -251,17 +276,28 @@ function KanbanCardDialogs({
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
/>
<TaskGitHubPRDialog
open={showPRDialog}
onOpenChange={setShowPRDialog}
open={menu.showPRDialog}
onOpenChange={menu.setShowPRDialog}
task={task}
repositories={repositories}
/>
<TaskGitHubIssueDialog
open={showIssueDialog}
onOpenChange={setShowIssueDialog}
open={menu.showIssueDialog}
onOpenChange={menu.setShowIssueDialog}
task={task}
repositories={repositories}
/>
{menu.externalLinkProvider && workspaceId && (
<TaskExternalLinkDialog
open={true}
onOpenChange={(open) => {
if (!open) menu.setExternalLinkProvider(null);
}}
provider={menu.externalLinkProvider}
task={task}
workspaceId={workspaceId}
/>
)}
</>
);
}
@@ -309,8 +345,69 @@ function useActiveWorkspaceRepositories() {
);
}
function KanbanCardFrame({
task,
repositoryChips,
draggable,
menu,
isPreviewed,
isSelected,
isMultiSelectMode,
showMaximizeButton,
isDeleting,
isArchiving,
onClick,
onToggleSelect,
onOpenFullPage,
}: Pick<
KanbanCardProps,
| "task"
| "repositoryChips"
| "isSelected"
| "isMultiSelectMode"
| "showMaximizeButton"
| "isDeleting"
| "isArchiving"
| "onToggleSelect"
| "onOpenFullPage"
> & {
draggable: ReturnType<typeof useDraggable>;
menu: KanbanCardMenuState;
isPreviewed: boolean;
onClick: (e: React.MouseEvent) => void;
}) {
return (
<KanbanCardContextMenu entries={menu.contextMenuEntries}>
<KanbanCardShell
task={task}
repositoryChips={repositoryChips}
attributes={draggable.attributes}
listeners={draggable.listeners}
setNodeRef={draggable.setNodeRef}
transform={draggable.transform}
isDragging={draggable.isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={menu.dropdownMenuEntries}
onClick={onClick}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelect?.(task.id);
}}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
);
}
export function KanbanCard({
task,
workspaceId,
externalLinkAvailability,
repositoryChips,
onClick,
onEdit,
@@ -328,25 +425,15 @@ export function KanbanCard({
onRangeSelect,
isMultiSelectMode,
}: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
const draggable = useDraggable({
id: task.id,
disabled: isMultiSelectMode,
});
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
const repositories = useActiveWorkspaceRepositories();
const {
dropdownMenuEntries,
contextMenuEntries,
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
showPRDialog,
setShowPRDialog,
showIssueDialog,
setShowIssueDialog,
} = useKanbanCardMenus({
const menu = useKanbanCardMenus({
task,
externalLinkAvailability,
steps,
isDeleting,
isArchiving,
@@ -368,41 +455,26 @@ export function KanbanCard({
return (
<>
<KanbanCardContextMenu entries={contextMenuEntries}>
<KanbanCardShell
task={task}
repositoryChips={repositoryChips}
attributes={attributes}
listeners={listeners}
setNodeRef={setNodeRef}
transform={transform}
isDragging={isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={dropdownMenuEntries}
onClick={handleClick}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelect?.(task.id);
}}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
<KanbanCardFrame
task={task}
repositoryChips={repositoryChips}
draggable={draggable}
menu={menu}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
onClick={handleClick}
onToggleSelect={onToggleSelect}
onOpenFullPage={onOpenFullPage}
/>
<KanbanCardDialogs
task={task}
workspaceId={workspaceId}
repositories={repositories}
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
showArchiveConfirm={showArchiveConfirm}
setShowArchiveConfirm={setShowArchiveConfirm}
showPRDialog={showPRDialog}
setShowPRDialog={setShowPRDialog}
showIssueDialog={showIssueDialog}
setShowIssueDialog={setShowIssueDialog}
menu={menu}
isDeleting={isDeleting}
isArchiving={isArchiving}
onDelete={onDelete}
+6
View File
@@ -6,6 +6,7 @@ import { KanbanCard, resolveTaskRepositoryChips, Task } from "./kanban-card";
import { Badge } from "@kandev/ui/badge";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import type { Repository } from "@/lib/types/http";
export interface WorkflowStep {
@@ -37,6 +38,7 @@ interface KanbanColumnProps {
/** Shift-click range select; receives the clicked id + this column's ordered ids. */
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
}
export function KanbanColumn({
@@ -57,10 +59,12 @@ export function KanbanColumn({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: step.id,
});
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
// Access repositories from store to pass repository names to cards
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
@@ -102,6 +106,8 @@ export function KanbanColumn({
<KanbanCard
key={task.id}
task={task}
workspaceId={activeWorkspaceId}
externalLinkAvailability={externalLinkAvailability}
repositoryChips={resolveTaskRepositoryChips(task, repositories)}
onClick={onPreviewTask}
onOpenFullPage={onOpenTask}
@@ -0,0 +1,21 @@
"use client";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
export type KanbanExternalLinkAvailability = {
jira: boolean;
linear: boolean;
sentry: boolean;
};
export function useKanbanExternalLinkAvailability(
workspaceId: string | null,
): KanbanExternalLinkAvailability {
return {
jira: useJiraAvailable(workspaceId),
linear: useLinearAvailable(workspaceId),
sentry: useSentryAvailable(workspaceId),
};
}
@@ -17,7 +17,7 @@ import { KanbanCardPreview } from "@/components/kanban-card-preview";
import type { WorkflowStep } from "@/components/kanban-column";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
import { useTaskActions } from "@/hooks/use-task-actions";
import { useAppStoreApi } from "@/components/state-provider";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { MobileColumnTabs } from "./mobile-column-tabs";
import { SwipeableColumns } from "./swipeable-columns";
@@ -25,6 +25,10 @@ import { MobileDropTargets } from "./mobile-drop-targets";
import { getKanbanColumnGridTemplate } from "./kanban-grid-template";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
import {
type KanbanExternalLinkAvailability,
useKanbanExternalLinkAvailability,
} from "@/components/kanban-external-link-availability";
export type SwimlaneKanbanContentProps = {
workflowId: string;
@@ -188,6 +192,7 @@ function MobileKanbanLayout({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}: {
steps: WorkflowStep[];
tasks: Task[];
@@ -207,6 +212,7 @@ function MobileKanbanLayout({
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
}) {
const taskCounts = useMemo(() => {
const counts: Record<string, number> = {};
@@ -244,6 +250,7 @@ function MobileKanbanLayout({
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
/>
<MobileDropTargets steps={steps} currentStepId={currentStepId} isDragging={!!activeTask} />
</div>
@@ -266,6 +273,7 @@ function TabletKanbanLayout({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}: {
steps: WorkflowStep[];
tasks: Task[];
@@ -282,6 +290,7 @@ function TabletKanbanLayout({
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
}) {
const getTasksForStep = useTasksByStep(tasks);
@@ -309,6 +318,7 @@ function TabletKanbanLayout({
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
/>
</div>
))}
@@ -333,6 +343,7 @@ function DesktopKanbanLayout({
onSelectRange,
isMultiSelectMode,
isCompactDesktop,
externalLinkAvailability,
}: {
steps: WorkflowStep[];
tasks: Task[];
@@ -350,6 +361,7 @@ function DesktopKanbanLayout({
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
isCompactDesktop: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
}) {
const getTasksForStep = useTasksByStep(tasks);
@@ -380,6 +392,7 @@ function DesktopKanbanLayout({
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
/>
))}
</div>
@@ -405,6 +418,8 @@ export function SwimlaneKanbanContent({
isMultiSelectMode,
}: SwimlaneKanbanContentProps) {
const { isMobile, isTablet, isCompactDesktop } = useResponsiveBreakpoint();
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const externalLinkAvailability = useKanbanExternalLinkAvailability(activeWorkspaceId);
const { activeIndex, setActiveIndex } = useMobileColumnIndex(steps, tasks);
const { sensors, handleDragStart, handleDragEnd, handleDragCancel, moveTaskToStep, activeTask } =
useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError });
@@ -429,6 +444,7 @@ export function SwimlaneKanbanContent({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}),
[
steps,
@@ -446,6 +462,7 @@ export function SwimlaneKanbanContent({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
],
);
@@ -5,6 +5,7 @@ import useEmblaCarousel from "embla-carousel-react";
import { KanbanColumn, WorkflowStep } from "../kanban-column";
import { Task } from "../kanban-card";
import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order";
import type { KanbanExternalLinkAvailability } from "../kanban-external-link-availability";
type SwipeableColumnsProps = {
steps: WorkflowStep[];
@@ -24,6 +25,7 @@ type SwipeableColumnsProps = {
onToggleSelect?: (taskId: string) => void;
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
};
/** Two-way sync between Embla's carousel position and the external activeIndex. */
@@ -80,6 +82,7 @@ export function SwipeableColumns({
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
}: SwipeableColumnsProps) {
// Stable options to avoid Embla reinitializing on every activeIndex change
const [initialIndex] = useState(activeIndex);
@@ -137,6 +140,7 @@ export function SwipeableColumns({
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
externalLinkAvailability={externalLinkAvailability}
hideHeader
/>
</div>
@@ -1,58 +0,0 @@
"use client";
import { useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { toast } from "sonner";
import { getLinearIssue } from "@/lib/api/domains/linear-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { LINEAR_KEY_RE } from "./linear-issue-common";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type LinearLinkButtonProps = {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | undefined | null;
};
// LinearLinkButton attaches a Linear issue to an existing task by prepending
// the identifier to its title ("ENG-123: ...").
export function LinearLinkButton({ taskId, workspaceId, taskTitle }: LinearLinkButtonProps) {
const available = useLinearAvailable(workspaceId);
const buildLinkedTitle = useCallback(
(key: string) => {
const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9]*-\d+:\s*/, "");
return stripped ? `${key}: ${stripped}` : key;
},
[taskTitle],
);
if (!available || !taskId || !workspaceId) return null;
return (
<ValidatedPopover
triggerStyle="outline-with-label"
triggerIcon={<IconLink className="h-4 w-4" />}
triggerLabel="Link Linear"
tooltip="Link this task to a Linear issue"
headline="Link to Linear issue"
placeholder="ENG-123 or paste issue URL"
extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null}
validationHint="Paste a Linear issue URL or identifier (ENG-123)"
// Run getLinearIssue *and* the title update inside the awaited fetch so
// a failure on either surfaces as the popover's inline error instead of
// an unhandled rejection.
fetch={async (key) => {
const issue = await getLinearIssue(key, { workspaceId });
await updateTask(taskId, { title: buildLinkedTitle(key) });
return issue;
}}
onSuccess={(key) => {
toast.success(`Linked to ${key}`);
}}
submitLabel="Link"
submittingLabel="Linking..."
/>
);
}
@@ -1,63 +0,0 @@
"use client";
import { useCallback } from "react";
import { IconLink } from "@tabler/icons-react";
import { toast } from "sonner";
import { getSentryIssue } from "@/lib/api/domains/sentry-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { SENTRY_SHORT_ID_RE } from "./sentry-issue-common";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
import { ValidatedPopover } from "@/components/integrations/validated-popover";
type SentryLinkButtonProps = {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | undefined | null;
};
// SentryLinkButton attaches a Sentry issue to an existing task by prepending
// its short ID to the title ("PROJ-123: ...").
export function SentryLinkButton({ taskId, workspaceId, taskTitle }: SentryLinkButtonProps) {
const available = useSentryAvailable(workspaceId);
const buildLinkedTitle = useCallback(
(key: string) => {
const stripped = (taskTitle ?? "").trim().replace(/^[A-Z][A-Z0-9_-]*-\d+:\s*/, "");
return stripped ? `${key}: ${stripped}` : key;
},
[taskTitle],
);
// Require taskTitle to be loaded (== null catches null + undefined but allows
// an empty string): otherwise linking would overwrite the real title with
// just the Sentry key while the title is still in flight.
if (!available || !taskId || !workspaceId || taskTitle == null) return null;
return (
<ValidatedPopover
triggerStyle="outline-with-label"
triggerIcon={<IconLink className="h-4 w-4" />}
triggerLabel="Link Sentry"
tooltip="Link this task to a Sentry issue"
headline="Link to Sentry issue"
placeholder="PROJ-123 or paste issue URL"
extractKey={(raw) => {
const upper = raw.toUpperCase().trim();
return SENTRY_SHORT_ID_RE.test(upper)
? upper
: (upper.match(/[A-Z][A-Z0-9_-]*-\d+/)?.[0] ?? null);
}}
validationHint="Paste a Sentry issue URL or short ID (PROJ-123)"
fetch={async (key) => {
const issue = await getSentryIssue(key, { workspaceId });
await updateTask(taskId, { title: buildLinkedTitle(key) });
return issue;
}}
onSuccess={(key) => {
toast.success(`Linked to ${key}`);
}}
submitLabel="Link"
submittingLabel="Linking..."
/>
);
}
@@ -10,13 +10,17 @@ import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar";
import type { StepDef } from "../task-switcher-context-menu";
import type { TaskMoveWorkflow } from "../task-move-context-menu";
import { applyView } from "@/lib/sidebar/apply-view";
import { useAppStore } from "@/components/state-provider";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog";
import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog";
import { SidebarLinkDialogs } from "../task-session-sidebar-dialogs";
import { useSidebarLinkActions } from "../task-session-sidebar-link-actions";
import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking";
import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks";
type SessionTaskSwitcherSheetProps = {
@@ -26,6 +30,19 @@ type SessionTaskSwitcherSheetProps = {
workflowId: string | null;
};
function useMobileTaskLinking(workspaceId: string | null) {
const store = useAppStoreApi();
const actions = useSidebarLinkActions(store);
const taskListHandlers = useSidebarTaskLinking(workspaceId, actions);
const { repositories } = useRepositories(workspaceId);
return {
actions,
repositories,
taskListHandlers,
};
}
export function MobileTaskList({
tasks,
workflows,
@@ -35,6 +52,11 @@ export function MobileTaskList({
onSelectTask,
onArchiveTask,
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
deletingTaskId,
isLoading,
}: {
@@ -46,6 +68,11 @@ export function MobileTaskList({
onSelectTask: (taskId: string) => void;
onArchiveTask: (taskId: string) => void;
onDeleteTask: (taskId: string) => Promise<void> | void;
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void;
onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void;
onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void;
deletingTaskId: string | null;
isLoading?: boolean;
}) {
@@ -88,6 +115,11 @@ export function MobileTaskList({
onSelectTask={onSelectTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
onLinkJiraTicket={onLinkJiraTicket}
onLinkLinearIssue={onLinkLinearIssue}
onLinkSentryIssue={onLinkSentryIssue}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
onReorderSubtasks={handleReorderSubtasks}
@@ -108,6 +140,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
const [dialogOpen, setDialogOpen] = useState(false);
const data = useSheetData(workspaceId);
const actions = useSheetActions(workspaceId, onOpenChange);
const linking = useMobileTaskLinking(workspaceId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -150,6 +183,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
onSelectTask={actions.handleSelectTask}
onArchiveTask={actions.handleArchiveTask}
onDeleteTask={actions.handleDeleteTask}
{...linking.taskListHandlers}
deletingTaskId={actions.deletingTaskId}
isLoading={data.tasksLoading}
/>
@@ -189,6 +223,11 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
isDeleting={actions.isDeleting}
onConfirm={({ cascade }) => actions.handleDeleteConfirm({ cascade })}
/>
<SidebarLinkDialogs
actions={linking.actions}
repositories={linking.repositories}
workspaceId={workspaceId}
/>
</Sheet>
);
});
@@ -0,0 +1,126 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { getJiraTicket } from "@/lib/api/domains/jira-api";
import { getLinearIssue } from "@/lib/api/domains/linear-api";
import { getSentryIssue } from "@/lib/api/domains/sentry-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { TaskExternalLinkDialog } from "./task-external-link-dialog";
vi.mock("@/lib/api/domains/jira-api", () => ({
getJiraTicket: vi.fn(),
}));
vi.mock("@/lib/api/domains/linear-api", () => ({
getLinearIssue: vi.fn(),
}));
vi.mock("@/lib/api/domains/sentry-api", () => ({
getSentryIssue: vi.fn(),
}));
vi.mock("@/lib/api/domains/kanban-api", () => ({
updateTask: vi.fn(),
}));
const INPUT_TEST_ID = "task-external-link-input";
const SUBMIT_TEST_ID = "task-external-link-submit";
const ERROR_TEST_ID = "task-external-link-error";
const TASK_ID = "task-1";
const WORKSPACE_ID = "workspace-1";
const TASK_TITLE = "Fix login";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function renderDialog(provider: "jira" | "linear" | "sentry" = "jira") {
return render(
<StateProvider>
<ToastProvider>
<TaskExternalLinkDialog
open={true}
onOpenChange={vi.fn()}
provider={provider}
task={{ id: TASK_ID, title: TASK_TITLE }}
workspaceId={WORKSPACE_ID}
/>
</ToastProvider>
</StateProvider>,
);
}
describe("TaskExternalLinkDialog", () => {
it("validates and links a Jira ticket by updating the task title", async () => {
vi.mocked(getJiraTicket).mockResolvedValue({ key: "PROJ-12" } as never);
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "PROJ-12: Fix login" } as never);
renderDialog("jira");
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
target: { value: "PROJ-12" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await waitFor(() => {
expect(getJiraTicket).toHaveBeenCalledWith("PROJ-12", { workspaceId: WORKSPACE_ID });
});
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "PROJ-12: Fix login" });
});
it("validates and links a Linear issue by updating the task title", async () => {
vi.mocked(getLinearIssue).mockResolvedValue({ identifier: "ENG-20" } as never);
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "ENG-20: Fix login" } as never);
renderDialog("linear");
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
target: { value: "eng-20" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await waitFor(() => {
expect(getLinearIssue).toHaveBeenCalledWith("ENG-20", { workspaceId: WORKSPACE_ID });
});
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "ENG-20: Fix login" });
});
it("extracts and links a Sentry issue key from a numeric issue URL", async () => {
vi.mocked(getSentryIssue).mockResolvedValue({ shortId: "API-42" } as never);
vi.mocked(updateTask).mockResolvedValue({ id: TASK_ID, title: "API-42: Fix login" } as never);
renderDialog("sentry");
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
target: { value: "https://sentry.io/organizations/acme/issues/123456/" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
await waitFor(() => {
expect(getSentryIssue).toHaveBeenCalledWith("123456", { workspaceId: WORKSPACE_ID });
});
expect(updateTask).toHaveBeenCalledWith(TASK_ID, { title: "API-42: Fix login" });
});
it("shows an inline validation error for invalid input", async () => {
renderDialog("jira");
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
target: { value: "not a key" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TEST_ID));
expect((await screen.findByTestId(ERROR_TEST_ID)).textContent).toMatch(
/Paste a Jira ticket URL or key/i,
);
expect(updateTask).not.toHaveBeenCalled();
fireEvent.change(screen.getByTestId(INPUT_TEST_ID), {
target: { value: "PROJ-12" },
});
expect(screen.queryByTestId(ERROR_TEST_ID)).toBeNull();
});
});
@@ -0,0 +1,205 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@kandev/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { useToast } from "@/components/toast-provider";
import { useAppStoreApi } from "@/components/state-provider";
import { getJiraTicket } from "@/lib/api/domains/jira-api";
import { getLinearIssue } from "@/lib/api/domains/linear-api";
import { getSentryIssue } from "@/lib/api/domains/sentry-api";
import { updateTask } from "@/lib/api/domains/kanban-api";
import { JIRA_KEY_RE } from "@/components/jira/jira-ticket-common";
import { LINEAR_KEY_RE } from "@/components/linear/linear-issue-common";
import { extractSentryShortId } from "@/components/sentry/sentry-issue-common";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import type { SentryIssue } from "@/lib/types/sentry";
import { buildLinkedIssueTitle } from "./task-external-link-utils";
export type ExternalLinkProvider = "jira" | "linear" | "sentry";
type TaskExternalLinkTarget = {
id: string;
title: string;
};
type TaskExternalLinkDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: ExternalLinkProvider;
task: TaskExternalLinkTarget;
workspaceId: string;
};
type ProviderConfig = {
title: string;
description: string;
inputLabel: string;
placeholder: string;
validationHint: string;
successLabel: string;
extractKey: (raw: string) => string | null;
fetch: (key: string, workspaceId: string) => Promise<unknown>;
resolveLinkedKey?: (requestedKey: string, result: unknown) => string;
};
const SENTRY_NUMERIC_ISSUE_URL_RE = /\/issues\/(\d+)(?:[/?#]|$)/i;
function extractSentryIssueKey(raw: string): string | null {
return extractSentryShortId(raw) ?? raw.match(SENTRY_NUMERIC_ISSUE_URL_RE)?.[1] ?? null;
}
function isSentryIssue(result: unknown): result is SentryIssue {
return (
typeof result === "object" &&
result !== null &&
typeof (result as Partial<SentryIssue>).shortId === "string"
);
}
const PROVIDERS: Record<ExternalLinkProvider, ProviderConfig> = {
jira: {
title: "Link Jira ticket",
description: "Use a Jira ticket key or URL for this task.",
inputLabel: "Ticket",
placeholder: "PROJ-123 or paste ticket URL",
validationHint: "Paste a Jira ticket URL or key (PROJ-123).",
successLabel: "Jira ticket linked",
extractKey: (raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null,
fetch: (key, workspaceId) => getJiraTicket(key, { workspaceId }),
},
linear: {
title: "Link Linear issue",
description: "Use a Linear issue identifier or URL for this task.",
inputLabel: "Issue",
placeholder: "ENG-123 or paste issue URL",
validationHint: "Paste a Linear issue URL or identifier (ENG-123).",
successLabel: "Linear issue linked",
extractKey: (raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null,
fetch: (key, workspaceId) => getLinearIssue(key, { workspaceId }),
},
sentry: {
title: "Link Sentry issue",
description: "Use a Sentry short ID or URL for this task.",
inputLabel: "Issue",
placeholder: "PROJ-123 or paste issue URL",
validationHint: "Paste a Sentry issue URL or short ID (PROJ-123).",
successLabel: "Sentry issue linked",
extractKey: extractSentryIssueKey,
fetch: (key, workspaceId) => getSentryIssue(key, { workspaceId }),
resolveLinkedKey: (requestedKey, result) =>
isSentryIssue(result) && result.shortId ? result.shortId : requestedKey,
},
};
export function TaskExternalLinkDialog({
open,
onOpenChange,
provider,
task,
workspaceId,
}: TaskExternalLinkDialogProps) {
const { toast } = useToast();
const store = useAppStoreApi();
const config = PROVIDERS[provider];
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setInput("");
setError(null);
}
}, [open]);
const submit = async () => {
const key = config.extractKey(input);
if (!key) {
setError(config.validationHint);
return;
}
setSubmitting(true);
setError(null);
try {
const result = await config.fetch(key, workspaceId);
const state = store.getState();
const latestTask = findTaskInSnapshots(
task.id,
state.kanbanMulti.snapshots,
state.kanban.tasks,
);
const linkedKey = config.resolveLinkedKey?.(key, result) ?? key;
await updateTask(task.id, {
title: buildLinkedIssueTitle(latestTask?.title ?? task.title, linkedKey),
});
toast({ description: config.successLabel, variant: "success" });
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to link ${config.inputLabel}.`);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{config.title}</DialogTitle>
<DialogDescription>{config.description}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="task-external-link-input">{config.inputLabel}</Label>
<Input
id="task-external-link-input"
data-testid="task-external-link-input"
value={input}
onChange={(event) => {
setInput(event.target.value);
if (error) setError(null);
}}
placeholder={config.placeholder}
disabled={submitting}
/>
{error && (
<p className="text-xs text-destructive" data-testid="task-external-link-error">
{error}
</p>
)}
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
className="cursor-pointer"
onClick={submit}
disabled={submitting || !input.trim()}
data-dialog-default-action
data-testid="task-external-link-submit"
>
{submitting ? "Saving" : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { buildLinkedIssueTitle } from "./task-external-link-utils";
describe("buildLinkedIssueTitle", () => {
it("prepends the external issue key to a normal title", () => {
expect(buildLinkedIssueTitle("Fix login", "PROJ-12")).toBe("PROJ-12: Fix login");
});
it("uses the key alone when the task title is empty", () => {
expect(buildLinkedIssueTitle(" ", "ENG-20")).toBe("ENG-20");
});
it("replaces an existing leading Jira/Linear/Sentry-style prefix", () => {
expect(buildLinkedIssueTitle("PROJ-12: Fix login", "ENG-20")).toBe("ENG-20: Fix login");
expect(buildLinkedIssueTitle("API-99: Debug production crash", "SVC-7")).toBe(
"SVC-7: Debug production crash",
);
});
});
@@ -0,0 +1,6 @@
const LEADING_EXTERNAL_ISSUE_RE = /^[A-Z][A-Z0-9_-]*-\d+:\s*/;
export function buildLinkedIssueTitle(taskTitle: string | null | undefined, key: string): string {
const stripped = (taskTitle ?? "").trim().replace(LEADING_EXTERNAL_ISSUE_RE, "");
return stripped ? `${key}: ${stripped}` : key;
}
+1 -1
View File
@@ -560,7 +560,7 @@ function TaskMenuButton({ visible }: { visible: boolean }) {
"self-center shrink-0 flex items-center transition-opacity duration-100",
visible
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto [@media(hover:none)]:opacity-100 [@media(hover:none)]:pointer-events-auto",
)}
>
<button
@@ -3,10 +3,14 @@
import { TaskRenameDialog } from "./task-rename-dialog";
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog";
import { TaskExternalLinkDialog } from "./task-external-link-dialog";
import { TaskGitHubIssueDialog } from "./task-github-issue-dialog";
import { TaskGitHubPRDialog } from "./task-github-pr-dialog";
import type { Repository } from "@/lib/types/http";
import type { SidebarLinkTarget } from "./task-session-sidebar-link-actions";
import type {
SidebarExternalLinkTarget,
SidebarLinkTarget,
} from "./task-session-sidebar-link-actions";
type Target = { id: string; title: string; executorType?: string | null } | null;
type LinkTarget = SidebarLinkTarget | null;
@@ -28,14 +32,18 @@ export type SidebarDialogsActions = {
setLinkingPullRequestTask: (next: LinkTarget) => void;
linkingIssueTask: LinkTarget;
setLinkingIssueTask: (next: LinkTarget) => void;
linkingExternalIssueTask: SidebarExternalLinkTarget | null;
setLinkingExternalIssueTask: (next: SidebarExternalLinkTarget | null) => void;
};
export function SidebarDialogs({
actions,
repositories,
workspaceId,
}: {
actions: SidebarDialogsActions;
repositories: Repository[];
workspaceId: string | null;
}) {
const {
renamingTask,
@@ -50,10 +58,6 @@ export function SidebarDialogs({
setDeletingTask,
isDeleting,
handleDeleteConfirm,
linkingPullRequestTask,
setLinkingPullRequestTask,
linkingIssueTask,
setLinkingIssueTask,
} = actions;
return (
<>
@@ -87,6 +91,38 @@ export function SidebarDialogs({
isDeleting={isDeleting}
onConfirm={handleDeleteConfirm}
/>
<SidebarLinkDialogs actions={actions} repositories={repositories} workspaceId={workspaceId} />
</>
);
}
export function SidebarLinkDialogs({
actions,
repositories,
workspaceId,
}: {
actions: Pick<
SidebarDialogsActions,
| "linkingPullRequestTask"
| "setLinkingPullRequestTask"
| "linkingIssueTask"
| "setLinkingIssueTask"
| "linkingExternalIssueTask"
| "setLinkingExternalIssueTask"
>;
repositories: Repository[];
workspaceId: string | null;
}) {
const {
linkingPullRequestTask,
setLinkingPullRequestTask,
linkingIssueTask,
setLinkingIssueTask,
linkingExternalIssueTask,
setLinkingExternalIssueTask,
} = actions;
return (
<>
{linkingPullRequestTask && (
<TaskGitHubPRDialog
open={true}
@@ -107,6 +143,17 @@ export function SidebarDialogs({
repositories={repositories}
/>
)}
{linkingExternalIssueTask && workspaceId && (
<TaskExternalLinkDialog
open={true}
onOpenChange={(open) => {
if (!open) setLinkingExternalIssueTask(null);
}}
provider={linkingExternalIssueTask.provider}
task={linkingExternalIssueTask.task}
workspaceId={workspaceId}
/>
)}
</>
);
}
@@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import type { KanbanState } from "@/lib/state/slices";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
import type { ExternalLinkProvider } from "./task-external-link-dialog";
type StoreApi = {
getState: () => {
@@ -20,19 +21,26 @@ export type SidebarLinkTarget = {
repositories?: Array<{ id?: string; repository_id: string; position?: number }>;
};
export type SidebarExternalLinkTarget = {
provider: ExternalLinkProvider;
task: SidebarLinkTarget;
};
export function useSidebarLinkActions(store: StoreApi) {
const [linkingPullRequestTask, setLinkingPullRequestTask] = useState<SidebarLinkTarget | null>(
null,
);
const [linkingIssueTask, setLinkingIssueTask] = useState<SidebarLinkTarget | null>(null);
const [linkingExternalIssueTask, setLinkingExternalIssueTask] =
useState<SidebarExternalLinkTarget | null>(null);
const getLinkTarget = useCallback(
(taskId: string): SidebarLinkTarget => {
(taskId: string, fallbackTitle?: string): SidebarLinkTarget => {
const state = store.getState();
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
return {
id: taskId,
title: task?.title ?? "this task",
title: task?.title ?? fallbackTitle ?? "this task",
repositoryId: task?.repositoryId,
issueUrl: task?.issueUrl,
issueNumber: task?.issueNumber,
@@ -43,19 +51,44 @@ export function useSidebarLinkActions(store: StoreApi) {
);
const handleLinkPullRequestTask = useCallback(
(taskId: string) => {
setLinkingPullRequestTask(getLinkTarget(taskId));
(taskId: string, fallbackTitle?: string) => {
setLinkingPullRequestTask(getLinkTarget(taskId, fallbackTitle));
},
[getLinkTarget],
);
const handleLinkIssueTask = useCallback(
(taskId: string) => {
setLinkingIssueTask(getLinkTarget(taskId));
(taskId: string, fallbackTitle?: string) => {
setLinkingIssueTask(getLinkTarget(taskId, fallbackTitle));
},
[getLinkTarget],
);
const handleLinkExternalIssueTask = useCallback(
(provider: ExternalLinkProvider, taskId: string, fallbackTitle?: string) => {
setLinkingExternalIssueTask({ provider, task: getLinkTarget(taskId, fallbackTitle) });
},
[getLinkTarget],
);
const handleLinkJiraTicketTask = useCallback(
(taskId: string, fallbackTitle?: string) =>
handleLinkExternalIssueTask("jira", taskId, fallbackTitle),
[handleLinkExternalIssueTask],
);
const handleLinkLinearIssueTask = useCallback(
(taskId: string, fallbackTitle?: string) =>
handleLinkExternalIssueTask("linear", taskId, fallbackTitle),
[handleLinkExternalIssueTask],
);
const handleLinkSentryIssueTask = useCallback(
(taskId: string, fallbackTitle?: string) =>
handleLinkExternalIssueTask("sentry", taskId, fallbackTitle),
[handleLinkExternalIssueTask],
);
return {
linkingPullRequestTask,
setLinkingPullRequestTask,
@@ -63,5 +96,10 @@ export function useSidebarLinkActions(store: StoreApi) {
linkingIssueTask,
setLinkingIssueTask,
handleLinkIssueTask,
linkingExternalIssueTask,
setLinkingExternalIssueTask,
handleLinkJiraTicketTask,
handleLinkLinearIssueTask,
handleLinkSentryIssueTask,
};
}
@@ -0,0 +1,29 @@
"use client";
import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability";
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
import type { useSidebarActions } from "./task-session-sidebar";
type SidebarActions = Pick<
ReturnType<typeof useSidebarActions>,
| "handleLinkPullRequestTask"
| "handleLinkIssueTask"
| "handleLinkJiraTicketTask"
| "handleLinkLinearIssueTask"
| "handleLinkSentryIssueTask"
>;
export function useSidebarTaskLinking(workspaceId: string | null, actions: SidebarActions) {
const jiraAvailable = useJiraAvailable(workspaceId);
const linearAvailable = useLinearAvailable(workspaceId);
const sentryAvailable = useSentryAvailable(workspaceId);
return {
onLinkPullRequest: actions.handleLinkPullRequestTask,
onLinkIssue: actions.handleLinkIssueTask,
onLinkJiraTicket: jiraAvailable ? actions.handleLinkJiraTicketTask : undefined,
onLinkLinearIssue: linearAvailable ? actions.handleLinkLinearIssueTask : undefined,
onLinkSentryIssue: sentryAvailable ? actions.handleLinkSentryIssueTask : undefined,
};
}
@@ -29,6 +29,7 @@ import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggr
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item";
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
import { useShallow } from "zustand/react/shallow";
import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error";
import {
@@ -97,7 +98,6 @@ function toIssueInfo(
: undefined;
}
/** Map a kanban task to a sidebar item with session info and repository metadata. */
function toSidebarItem(
task: KanbanState["tasks"][number] & { _workflowId: string },
ctx: SidebarCtx,
@@ -189,9 +189,7 @@ function useSidebarData(workspaceId: string | null) {
isLoading: isLoadingWorkflow,
} = useWorkspaceSidebarTasks(workspaceId);
// Stable list of primary session IDs for the bulk-subscribe effect and the
// narrow pending-flag selector below. Derived from kanban tasks (always
// available) rather than sessionsByTaskId (loaded on-demand).
// Stable primary session IDs from kanban tasks feed subscriptions and pending-flag selectors.
const primarySessionIds = useStablePrimarySessionIds(allTasks);
const acknowledgementSessionIds = useMemo(
() => agentErrorAcknowledgementSessionIds(allTasks, sessionsByTaskId),
@@ -436,7 +434,7 @@ function useDeleteActions(
};
}
function useSidebarActions(store: StoreApi) {
export function useSidebarActions(store: StoreApi) {
const setActiveTask = useAppStore((state) => state.setActiveTask);
const setActiveSession = useAppStore((state) => state.setActiveSession);
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
@@ -528,7 +526,6 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) {
if (connectionStatus !== "connected" || primarySessionIds.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
// Skip active session — it's already subscribed + focused by the task page hooks
const backgroundIds = activeSessionId
? primarySessionIds.filter((id) => id !== activeSessionId)
: primarySessionIds;
@@ -556,9 +553,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
primarySessionIds,
} = useSidebarData(workspaceId);
// The sidebar is global, so `activeTaskId` lingers after navigating Home.
// Only highlight a task while actually viewing a task route — otherwise the
// last-opened task stays visually "selected" on Home and elsewhere.
// Only highlight while viewing a task route; AppSidebar is global and activeTaskId lingers.
const onTaskRoute =
!!pathname && (pathname.startsWith("/t/") || pathname.startsWith("/office/tasks/"));
const highlightedTaskId = onTaskRoute ? activeTaskId : null;
@@ -575,9 +570,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
handleDeleteTask,
handleMoveToStep,
handleRenameTask,
handleLinkPullRequestTask,
handleLinkIssueTask,
} = sidebarActions;
const taskLinkHandlers = useSidebarTaskLinking(workspaceId, sidebarActions);
const repositories =
useAppStore((state) =>
workspaceId ? state.repositories.itemsByWorkspaceId[workspaceId] : undefined,
@@ -626,8 +620,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
onRenameTask={handleRenameTask}
onArchiveTask={handleArchiveTask}
onDeleteTask={handleDeleteTask}
onLinkPullRequest={handleLinkPullRequestTask}
onLinkIssue={handleLinkIssueTask}
{...taskLinkHandlers}
onMoveToStep={handleMoveToStep}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
@@ -639,7 +632,11 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
{...selection.switcherProps}
/>
</PanelBody>
<SidebarDialogs actions={sidebarActions} repositories={repositories} />
<SidebarDialogs
actions={sidebarActions}
repositories={repositories}
workspaceId={workspaceId}
/>
<SidebarBulkDialogs selection={selection} />
</PanelRoot>
);
@@ -3,6 +3,7 @@
import { cloneElement, isValidElement, useState } from "react";
import {
IconArchive,
IconBrandSentry,
IconCopy,
IconCircleDot,
IconGitPullRequest,
@@ -11,6 +12,7 @@ import {
IconPencil,
IconPin,
IconPinFilled,
IconTicket,
IconTrash,
} from "@tabler/icons-react";
import {
@@ -47,8 +49,11 @@ type ContextMenuProps = {
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
onLinkPullRequest?: (taskId: string) => void;
onLinkIssue?: (taskId: string) => void;
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void;
onLinkLinearIssue?: (taskId: string, taskTitle?: string) => void;
onLinkSentryIssue?: (taskId: string, taskTitle?: string) => void;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
isPinned?: boolean;
@@ -76,6 +81,9 @@ export function TaskItemWithContextMenu({
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
onMoveToStep,
onTogglePin,
isPinned,
@@ -114,6 +122,9 @@ export function TaskItemWithContextMenu({
onDeleteTask={onDeleteTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
onLinkJiraTicket={onLinkJiraTicket}
onLinkLinearIssue={onLinkLinearIssue}
onLinkSentryIssue={onLinkSentryIssue}
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
isPinned={isPinned}
@@ -139,31 +150,30 @@ type TaskContextMenuItemsProps = Omit<ContextMenuProps, "children"> & {
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
};
function TaskContextMenuItems({
task,
workflows,
stepsByWorkflowId,
steps,
onRenameTask,
onArchiveTask,
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onMoveToStep,
onTogglePin,
isPinned,
pinnedTaskIds,
isDeleting,
selectedTaskIds,
onBulkArchive,
onBulkDelete,
onBulkPin,
onBulkMove,
onClearSelection,
isMixedWorkflowSelection,
closeMenu,
moveTasks,
}: TaskContextMenuItemsProps) {
function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
const {
task,
workflows,
stepsByWorkflowId,
steps,
onRenameTask,
onArchiveTask,
onDeleteTask,
onMoveToStep,
onTogglePin,
isPinned,
pinnedTaskIds,
isDeleting,
selectedTaskIds,
onBulkArchive,
onBulkDelete,
onBulkPin,
onBulkMove,
onClearSelection,
isMixedWorkflowSelection,
closeMenu,
moveTasks,
} = props;
// Right-clicking any row that's part of the active selection acts on the whole
// selection (even a one-row selection, so the action clears it); right-clicking
// a non-selected row acts on just that task and leaves the selection intact.
@@ -225,11 +235,7 @@ function TaskContextMenuItems({
onBulkArchive={onBulkArchive}
/>
<TaskColorMenu taskId={task.id} disabled={isDeleting} />
<TaskLinkMenu
disabled={isDeleting}
onLinkPullRequest={selectTaskAction(task.id, onLinkPullRequest, closeMenu)}
onLinkIssue={selectTaskAction(task.id, onLinkIssue, closeMenu)}
/>
<TaskLinkMenu disabled={isDeleting} {...selectTaskLinkActions(task, closeMenu, props)} />
<TaskMoveItems
task={task}
workflows={workflows}
@@ -328,15 +334,36 @@ function BulkSelectionMenuItems({
);
}
function selectTaskAction(
taskId: string,
handler: ((taskId: string) => void) | undefined,
export function createTaskLinkSelectAction(
task: Pick<TaskSwitcherItem, "id" | "title">,
handler: ((taskId: string, taskTitle?: string) => void) | undefined,
closeMenu: () => void,
) {
if (!handler) return undefined;
return () => {
closeMenu();
handler(taskId);
handler(task.id, task.title);
};
}
function selectTaskLinkActions(
task: Pick<TaskSwitcherItem, "id" | "title">,
closeMenu: () => void,
handlers: Pick<
ContextMenuProps,
| "onLinkPullRequest"
| "onLinkIssue"
| "onLinkJiraTicket"
| "onLinkLinearIssue"
| "onLinkSentryIssue"
>,
) {
return {
onLinkPullRequest: createTaskLinkSelectAction(task, handlers.onLinkPullRequest, closeMenu),
onLinkIssue: createTaskLinkSelectAction(task, handlers.onLinkIssue, closeMenu),
onLinkJiraTicket: createTaskLinkSelectAction(task, handlers.onLinkJiraTicket, closeMenu),
onLinkLinearIssue: createTaskLinkSelectAction(task, handlers.onLinkLinearIssue, closeMenu),
onLinkSentryIssue: createTaskLinkSelectAction(task, handlers.onLinkSentryIssue, closeMenu),
};
}
@@ -537,12 +564,26 @@ function TaskLinkMenu({
disabled,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
}: {
disabled?: boolean;
onLinkPullRequest?: () => void;
onLinkIssue?: () => void;
onLinkJiraTicket?: () => void;
onLinkLinearIssue?: () => void;
onLinkSentryIssue?: () => void;
}) {
if (!onLinkPullRequest && !onLinkIssue) return null;
if (
!onLinkPullRequest &&
!onLinkIssue &&
!onLinkJiraTicket &&
!onLinkLinearIssue &&
!onLinkSentryIssue
) {
return null;
}
return (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
@@ -550,14 +591,36 @@ function TaskLinkMenu({
Link
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-56">
<ContextMenuItem disabled={disabled || !onLinkPullRequest} onSelect={onLinkPullRequest}>
<IconGitPullRequest className="mr-2 h-4 w-4" />
GitHub Pull Request
</ContextMenuItem>
<ContextMenuItem disabled={disabled || !onLinkIssue} onSelect={onLinkIssue}>
<IconCircleDot className="mr-2 h-4 w-4" />
GitHub Issue
</ContextMenuItem>
{onLinkPullRequest && (
<ContextMenuItem disabled={disabled} onSelect={onLinkPullRequest}>
<IconGitPullRequest className="mr-2 h-4 w-4" />
GitHub Pull Request
</ContextMenuItem>
)}
{onLinkIssue && (
<ContextMenuItem disabled={disabled} onSelect={onLinkIssue}>
<IconCircleDot className="mr-2 h-4 w-4" />
GitHub Issue
</ContextMenuItem>
)}
{onLinkJiraTicket && (
<ContextMenuItem disabled={disabled} onSelect={onLinkJiraTicket}>
<IconTicket className="mr-2 h-4 w-4" />
Jira Ticket
</ContextMenuItem>
)}
{onLinkLinearIssue && (
<ContextMenuItem disabled={disabled} onSelect={onLinkLinearIssue}>
<IconCircleDot className="mr-2 h-4 w-4" />
Linear Issue
</ContextMenuItem>
)}
{onLinkSentryIssue && (
<ContextMenuItem disabled={disabled} onSelect={onLinkSentryIssue}>
<IconBrandSentry className="mr-2 h-4 w-4" />
Sentry Issue
</ContextMenuItem>
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
@@ -1,8 +1,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
import { createTaskLinkSelectAction } from "./task-switcher-context-menu";
import type { GroupedSidebarList } from "@/lib/sidebar/apply-view";
afterEach(() => cleanup());
@@ -163,3 +164,54 @@ describe("TaskSwitcher — bulk pin menu", () => {
expect(screen.getByText("Unpin 2 tasks")).toBeTruthy();
});
});
describe("TaskSwitcher — external issue link menu", () => {
it("offers configured external issue providers from the task context menu", async () => {
render(
<Providers>
<TaskSwitcher
grouped={{
groups: [{ key: "__all__", label: "All", tasks: [TASK_A] }],
subTasksByParentId: new Map(),
}}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onLinkPullRequest={vi.fn()}
onLinkIssue={vi.fn()}
{...({
onLinkJiraTicket: vi.fn(),
onLinkLinearIssue: vi.fn(),
onLinkSentryIssue: vi.fn(),
} as Partial<Parameters<typeof TaskSwitcher>[0]>)}
/>
</Providers>,
);
fireEvent.contextMenu(screen.getByText(TASK_A.title));
const linkTrigger = screen.getByText("Link");
fireEvent.focus(linkTrigger);
fireEvent.keyDown(linkTrigger, { key: "ArrowRight" });
await waitFor(() => {
expect(screen.getByText("Jira Ticket")).toBeTruthy();
expect(screen.getByText("Linear Issue")).toBeTruthy();
expect(screen.getByText("Sentry Issue")).toBeTruthy();
});
});
it("passes the visible task title to external issue link handlers", () => {
const archivedTask: TaskSwitcherItem = {
id: "archived-task",
title: "Archived task title",
isArchived: true,
};
const closeMenu = vi.fn();
const onLinkJiraTicket = vi.fn();
createTaskLinkSelectAction(archivedTask, onLinkJiraTicket, closeMenu)?.();
expect(onLinkJiraTicket).toHaveBeenCalledWith(archivedTask.id, archivedTask.title);
expect(closeMenu).toHaveBeenCalledOnce();
});
});
+49 -10
View File
@@ -58,8 +58,11 @@ type TaskSwitcherProps = {
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
onLinkPullRequest?: (taskId: string) => void;
onLinkIssue?: (taskId: string) => void;
onLinkPullRequest?: TaskLinkHandler;
onLinkIssue?: TaskLinkHandler;
onLinkJiraTicket?: TaskLinkHandler;
onLinkLinearIssue?: TaskLinkHandler;
onLinkSentryIssue?: TaskLinkHandler;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
onReorderGroup?: (groupTaskIds: string[]) => void;
@@ -81,6 +84,8 @@ type TaskSwitcherProps = {
isMixedWorkflowSelection?: boolean;
};
type TaskLinkHandler = (taskId: string, taskTitle?: string) => void;
/**
* Modifier-aware sidebar row click: cmd/ctrl toggles one task, shift extends a
* range, a plain click toggles while a selection is active and otherwise
@@ -136,8 +141,11 @@ type TaskRowProps = {
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
onLinkPullRequest?: (taskId: string) => void;
onLinkIssue?: (taskId: string) => void;
onLinkPullRequest?: TaskLinkHandler;
onLinkIssue?: TaskLinkHandler;
onLinkJiraTicket?: TaskLinkHandler;
onLinkLinearIssue?: TaskLinkHandler;
onLinkSentryIssue?: TaskLinkHandler;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
isPinned?: boolean;
@@ -154,6 +162,24 @@ type TaskRowProps = {
isMixedWorkflowSelection?: boolean;
};
function taskLinkHandlerProps(props: Pick<TaskRowProps, keyof TaskLinkHandlerProps>) {
return {
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
};
}
type TaskLinkHandlerProps = {
onLinkPullRequest?: TaskLinkHandler;
onLinkIssue?: TaskLinkHandler;
onLinkJiraTicket?: TaskLinkHandler;
onLinkLinearIssue?: TaskLinkHandler;
onLinkSentryIssue?: TaskLinkHandler;
};
function TaskRow({
task,
isSubTask,
@@ -167,8 +193,6 @@ function TaskRow({
onRenameTask,
onArchiveTask,
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onMoveToStep,
onTogglePin,
isPinned,
@@ -183,6 +207,7 @@ function TaskRow({
onBulkMove,
onClearSelection,
isMixedWorkflowSelection,
...props
}: TaskRowProps) {
const isSelected = task.id === selectedTaskId || task.id === activeTaskId;
const isMultiSelected = selectedTaskIds?.has(task.id) ?? false;
@@ -197,8 +222,7 @@ function TaskRow({
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
{...taskLinkHandlerProps(props)}
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
isPinned={isPinned}
@@ -372,8 +396,11 @@ type GroupSectionProps = {
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
onLinkPullRequest?: (taskId: string) => void;
onLinkIssue?: (taskId: string) => void;
onLinkPullRequest?: TaskLinkHandler;
onLinkIssue?: TaskLinkHandler;
onLinkJiraTicket?: TaskLinkHandler;
onLinkLinearIssue?: TaskLinkHandler;
onLinkSentryIssue?: TaskLinkHandler;
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
onReorderGroup?: (groupTaskIds: string[]) => void;
@@ -410,6 +437,9 @@ function GroupSection({
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
onMoveToStep,
onTogglePin,
onReorderGroup,
@@ -444,6 +474,9 @@ function GroupSection({
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
onMoveToStep,
onTogglePin,
pinnedTaskIds,
@@ -496,6 +529,9 @@ export const TaskSwitcher = memo(function TaskSwitcher({
onDeleteTask,
onLinkPullRequest,
onLinkIssue,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
onMoveToStep,
onTogglePin,
onReorderGroup,
@@ -548,6 +584,9 @@ export const TaskSwitcher = memo(function TaskSwitcher({
onDeleteTask={onDeleteTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
onLinkJiraTicket={onLinkJiraTicket}
onLinkLinearIssue={onLinkLinearIssue}
onLinkSentryIssue={onLinkSentryIssue}
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
onReorderGroup={onReorderGroup}
@@ -44,19 +44,11 @@ vi.mock("@/components/jira/jira-ticket-button", () => ({
extractJiraKey: () => null,
}));
vi.mock("@/components/jira/jira-link-button", () => ({
JiraLinkButton: () => null,
}));
vi.mock("@/components/linear/linear-issue-button", () => ({
LinearIssueButton: () => null,
extractLinearKey: () => null,
}));
vi.mock("@/components/linear/linear-link-button", () => ({
LinearLinkButton: () => null,
}));
vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({
useJiraAvailable: () => false,
}));
+8 -23
View File
@@ -12,9 +12,7 @@ import { DocumentControls } from "@/components/task/document/document-controls";
import { PRTopbarButton } from "@/components/github/pr-topbar-button";
import { MRTopbarButton } from "@/components/gitlab/mr-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";
@@ -96,7 +94,6 @@ const TaskTopBar = memo(function TaskTopBar({
)}
</div>
<TopBarRight
taskId={taskId}
activeSessionId={activeSessionId}
showDebugOverlay={showDebugOverlay}
onToggleDebugOverlay={onToggleDebugOverlay}
@@ -113,18 +110,16 @@ const TaskTopBar = memo(function TaskTopBar({
);
});
// 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).
// IssueTrackerButtons picks the right ticket status button for a task whose
// title already carries an external issue key. 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. Creating new links lives in the task Link menu.
function IssueTrackerButtons({
taskId,
workspaceId,
taskTitle,
}: {
taskId: string | null | undefined;
workspaceId: string | null | undefined;
taskTitle: string | null | undefined;
}) {
@@ -139,12 +134,7 @@ function IssueTrackerButtons({
if (linearKey && linearAvailable) {
return <LinearIssueButton workspaceId={workspaceId} taskTitle={taskTitle} />;
}
return (
<>
<JiraLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<LinearLinkButton taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
</>
);
return null;
}
/** Left section: task name breadcrumb + executor info. Home + integrations
@@ -227,7 +217,6 @@ function DebugOverlayToggle({
}
function AttentionStatusGroup({
taskId,
activeSessionId,
isArchived,
workspaceId,
@@ -237,7 +226,6 @@ function AttentionStatusGroup({
issueUrl,
issueNumber,
}: {
taskId?: string | null;
activeSessionId?: string | null;
isArchived?: boolean;
workspaceId?: string | null;
@@ -273,7 +261,7 @@ function AttentionStatusGroup({
<GitHubIssueTopbarButton issueUrl={issueUrl} issueNumber={issueNumber} />
<PRTopbarButton />
<MRTopbarButton />
<IssueTrackerButtons taskId={taskId} workspaceId={workspaceId} taskTitle={taskTitle} />
<IssueTrackerButtons workspaceId={workspaceId} taskTitle={taskTitle} />
</>
)}
</TopbarCluster>
@@ -347,7 +335,6 @@ function TopbarToolsGroup({
* The former overflow popover was removed in the UI overhaul — every cluster
* is always visible so users don't have to discover the dots menu. */
function TopBarRight({
taskId,
activeSessionId,
showDebugOverlay,
onToggleDebugOverlay,
@@ -360,7 +347,6 @@ function TopBarRight({
issueNumber,
officeTaskHref,
}: {
taskId?: string | null;
activeSessionId?: string | null;
showDebugOverlay?: boolean;
onToggleDebugOverlay?: () => void;
@@ -384,7 +370,6 @@ function TopBarRight({
</TopbarCluster>
)}
<AttentionStatusGroup
taskId={taskId}
activeSessionId={activeSessionId}
isArchived={isArchived}
workspaceId={workspaceId}
+14
View File
@@ -1473,6 +1473,20 @@ export class ApiClient {
});
}
async setSentryConfig(payload: {
secret: string;
url?: string;
workspaceId?: string;
}): Promise<unknown> {
const { workspaceId, ...config } = payload;
const path = await this.withActiveWorkspace("/api/v1/sentry/config", workspaceId);
return this.request("PUT", path, {
authMethod: "auth_token",
url: "https://sentry.io",
...config,
});
}
/**
* Poll until the integration config reports `lastOk: true`. SetConfig kicks
* off an async auth-health probe in a goroutine, so the row's `lastOk` flips
@@ -0,0 +1,49 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
test.describe("Mobile sidebar — external link menu", () => {
test("shows enabled integration link actions in the task switcher sheet", async ({
testPage,
apiClient,
seedData,
}) => {
await apiClient.setJiraConfig({
siteUrl: "https://acme.atlassian.net",
email: "alice@example.com",
secret: "api-token-value",
});
await apiClient.setLinearConfig({ secret: "lin_api_xxx" });
await apiClient.setSentryConfig({ secret: "sntrys_xxx" });
await Promise.all([
apiClient.waitForIntegrationAuthHealthy("jira"),
apiClient.waitForIntegrationAuthHealthy("linear"),
apiClient.waitForIntegrationAuthHealthy("sentry"),
]);
const task = await apiClient.seedTask(seedData.workspaceId, "Mobile external link task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.task_id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").click();
const sheet = testPage.getByRole("dialog");
const taskRow = sheet.locator('[role="button"]').filter({
hasText: "Mobile external link task",
});
await expect(taskRow).toBeVisible({ timeout: 10_000 });
await taskRow.getByRole("button", { name: "Task actions" }).click();
const linkTrigger = testPage.getByRole("menuitem", { name: /^Link$/ });
await expect(linkTrigger).toBeVisible();
await linkTrigger.focus();
await testPage.keyboard.press("ArrowRight");
await expect(testPage.getByRole("menuitem", { name: "Jira Ticket" })).toBeVisible();
await expect(testPage.getByRole("menuitem", { name: "Linear Issue" })).toBeVisible();
await expect(testPage.getByRole("menuitem", { name: "Sentry Issue" })).toBeVisible();
});
});
@@ -4,16 +4,21 @@ created: 2026-06-23
owner: product
---
# Link Existing Task to GitHub References
# Link Existing Task to External References
## Why
Users can create a task from GitHub, but they also need to attach GitHub references to a task that already exists. That breaks traceability when a task is started first and the GitHub pull request or issue is created later.
Users can create tasks from external systems, but they also need to attach
external references to a task that already exists. Traceability breaks when a
task starts first and the GitHub pull request, GitHub issue, Jira ticket, Linear
issue, or Sentry issue is identified later.
## What
- Any existing task can open a **Link** submenu from task menus.
- The submenu contains **GitHub Pull Request** and **GitHub Issue** actions.
- The submenu contains **GitHub Pull Request**, **GitHub Issue**, **Jira
Ticket**, **Linear Issue**, and **Sentry Issue** actions when each action is
available for the task workspace.
- The actions are available from task card context/dropdown menus and sidebar task context menus.
- The issue action accepts a GitHub issue URL. If the task has exactly one GitHub repository, an issue number such as `#1470` is also accepted.
- The pull request action accepts a GitHub PR URL. If the task has exactly one GitHub repository, a PR number such as `#1471` is also accepted.
@@ -21,12 +26,32 @@ Users can create a task from GitHub, but they also need to attach GitHub referen
- The link is stored in task metadata using the existing `issue_url` and `issue_number` fields, so kanban cards and task detail surfaces render it through the existing issue indicator.
- Pull request linking reuses the existing task PR association model and rendering.
- A linked issue can be explicitly changed or unlinked from the same dialog.
- Jira ticket linking is shown only when Jira is enabled and healthy for the
current workspace. It accepts a Jira key or URL, validates it through the
configured Jira integration, and links by rewriting the task title prefix to
`KEY: existing title`.
- Linear issue linking is shown only when Linear is enabled and healthy for the
current workspace. It accepts a Linear identifier or URL, validates it through
the configured Linear integration, and links by rewriting the task title
prefix to `IDENTIFIER: existing title`.
- Sentry issue linking is shown only when Sentry is enabled and healthy for the
current workspace. It accepts a Sentry short ID or URL, validates it through
the configured Sentry integration, and links by rewriting the task title
prefix to `SHORT-ID: existing title`.
- Jira, Linear, and Sentry title-prefix linking replaces an existing leading
external issue prefix instead of stacking prefixes.
- The dockview top bar does not show Jira or Linear create-link buttons for
unlinked tasks. It still shows existing linked-reference affordances, such as
Jira/Linear ticket buttons when a linked key is present in the title.
## Out of Scope
- Creating GitHub issues from Kandev.
- Creating GitHub pull requests from Kandev.
- New issue synchronization beyond the existing metadata-backed reference.
- Creating Jira tickets, Linear issues, or Sentry issues from Kandev.
- A durable cross-provider `task_external_links` model.
- A Sentry linked-issue top-bar affordance.
## Scenarios
@@ -60,8 +85,33 @@ GIVEN an existing task that already has GitHub issue metadata
WHEN the user opens Link > GitHub Issue and chooses Unlink
THEN Kandev removes only the issue metadata keys and preserves unrelated task metadata
### Link a Jira ticket to an existing task
GIVEN Jira is enabled and healthy for the task workspace
WHEN the user opens Link > Jira Ticket and enters `PROJ-12`
THEN Kandev validates the ticket through Jira and renames the task to `PROJ-12: <old title>` without changing task state, session history, repositories, or unrelated metadata
### Link a Linear issue to an existing task
GIVEN Linear is enabled and healthy for the task workspace
WHEN the user opens Link > Linear Issue and enters `ENG-20`
THEN Kandev validates the issue through Linear and renames the task to `ENG-20: <old title>` without changing task state, session history, repositories, or unrelated metadata
### Link a Sentry issue to an existing task
GIVEN Sentry is enabled and healthy for the task workspace
WHEN the user opens Link > Sentry Issue and enters `API-99`
THEN Kandev validates the issue through Sentry and renames the task to `API-99: <old title>` without changing task state, session history, repositories, or unrelated metadata
### Replace an existing external prefix
GIVEN an existing task titled `PROJ-12: Fix login`
WHEN the user opens Link > Linear Issue and enters `ENG-20`
THEN Kandev renames the task to `ENG-20: Fix login` instead of stacking prefixes
## Success Criteria
- Linking does not change task state, session history, repositories, or unrelated metadata.
- Invalid issue references and repository mismatches return clear errors.
- Right-click context menu, sidebar menu, and touch/dropdown menu users can reach the Link submenu.
- Jira, Linear, and Sentry actions are hidden when the corresponding integration is disabled or unauthenticated.