From 4eae00964dc01334ddfc437b3424b6c324779c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:43:35 +0100 Subject: [PATCH] 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 --- .../integrations/validated-popover.tsx | 5 +- apps/web/components/jira/jira-link-button.tsx | 63 ---- apps/web/components/kanban-board-grid.tsx | 17 +- .../kanban-card-menu-items.test.tsx | 51 ++- .../web/components/kanban-card-menu-items.tsx | 104 ++++-- apps/web/components/kanban-card.tsx | 298 +++++++++++------- apps/web/components/kanban-column.tsx | 6 + .../kanban-external-link-availability.ts | 21 ++ .../kanban/swimlane-kanban-content.tsx | 19 +- .../components/kanban/swipeable-columns.tsx | 4 + .../components/linear/linear-link-button.tsx | 58 ---- .../components/sentry/sentry-link-button.tsx | 63 ---- .../mobile/session-task-switcher-sheet.tsx | 41 ++- .../task/task-external-link-dialog.test.tsx | 126 ++++++++ .../task/task-external-link-dialog.tsx | 205 ++++++++++++ .../task/task-external-link-utils.test.ts | 19 ++ .../task/task-external-link-utils.ts | 6 + apps/web/components/task/task-item.tsx | 2 +- .../task/task-session-sidebar-dialogs.tsx | 57 +++- .../task/task-session-sidebar-link-actions.ts | 50 ++- .../task/task-session-sidebar-task-linking.ts | 29 ++ .../components/task/task-session-sidebar.tsx | 25 +- .../task/task-switcher-context-menu.tsx | 153 ++++++--- .../components/task/task-switcher.test.tsx | 54 +++- apps/web/components/task/task-switcher.tsx | 59 +++- .../web/components/task/task-top-bar.test.tsx | 8 - apps/web/components/task/task-top-bar.tsx | 31 +- apps/web/e2e/helpers/api-client.ts | 14 + .../task/mobile-external-link-menu.spec.ts | 49 +++ .../tasks/link-existing-task-github-issue.md | 56 +++- 30 files changed, 1252 insertions(+), 441 deletions(-) delete mode 100644 apps/web/components/jira/jira-link-button.tsx create mode 100644 apps/web/components/kanban-external-link-availability.ts delete mode 100644 apps/web/components/linear/linear-link-button.tsx delete mode 100644 apps/web/components/sentry/sentry-link-button.tsx create mode 100644 apps/web/components/task/task-external-link-dialog.test.tsx create mode 100644 apps/web/components/task/task-external-link-dialog.tsx create mode 100644 apps/web/components/task/task-external-link-utils.test.ts create mode 100644 apps/web/components/task/task-external-link-utils.ts create mode 100644 apps/web/components/task/task-session-sidebar-task-linking.ts create mode 100644 apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts diff --git a/apps/web/components/integrations/validated-popover.tsx b/apps/web/components/integrations/validated-popover.tsx index 5ffc5c569b..15d8873db2 100644 --- a/apps/web/components/integrations/validated-popover.tsx +++ b/apps/web/components/integrations/validated-popover.tsx @@ -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"; diff --git a/apps/web/components/jira/jira-link-button.tsx b/apps/web/components/jira/jira-link-button.tsx deleted file mode 100644 index 999170c9ee..0000000000 --- a/apps/web/components/jira/jira-link-button.tsx +++ /dev/null @@ -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 ( - } - 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..." - /> - ); -} diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 7307129e93..1af8723399 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -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} /> @@ -184,6 +193,7 @@ function TabletLayout({ showMaximizeButton={showMaximizeButton} deletingTaskId={deletingTaskId} archivingTaskId={archivingTaskId} + externalLinkAvailability={externalLinkAvailability} /> ))} @@ -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} /> ))} @@ -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; diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index 75d088b2a4..61084d9408 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -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"]); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 7b130aa589..4afa98a50b 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -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: , + label: "GitHub Pull Request", + disabled, + onSelect: onLinkPullRequest, + }); + } + if (onLinkIssue) { + children.push({ + kind: "item", + key: "link-github-issue", + testId: "task-context-link-github-issue", + icon: , + label: "GitHub Issue", + disabled, + onSelect: onLinkIssue, + }); + } + if (onLinkJiraTicket) { + children.push({ + kind: "item", + key: "link-jira-ticket", + testId: "task-context-link-jira-ticket", + icon: , + label: "Jira Ticket", + disabled, + onSelect: onLinkJiraTicket, + }); + } + if (onLinkLinearIssue) { + children.push({ + kind: "item", + key: "link-linear-issue", + testId: "task-context-link-linear-issue", + icon: , + label: "Linear Issue", + disabled, + onSelect: onLinkLinearIssue, + }); + } + if (onLinkSentryIssue) { + children.push({ + kind: "item", + key: "link-sentry-issue", + testId: "task-context-link-sentry-issue", + icon: , + 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: , - label: "GitHub Pull Request", - disabled: disabled || !onLinkPullRequest, - onSelect: onLinkPullRequest, - }, - { - kind: "item", - key: "link-github-issue", - testId: "task-context-link-github-issue", - icon: , - 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); diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 69b2823388..aaff8b2d67 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -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) { 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( + 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; + 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 ( <> onDelete?.(task, { cascade })} /> onArchive?.(task, { cascade })} /> + {menu.externalLinkProvider && workspaceId && ( + { + 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; + menu: KanbanCardMenuState; + isPreviewed: boolean; + onClick: (e: React.MouseEvent) => void; +}) { + return ( + + { + e.stopPropagation(); + onToggleSelect?.(task.id); + }} + onOpenFullPage={onOpenFullPage} + /> + + ); +} + 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 ( <> - - { - e.stopPropagation(); - onToggleSelect?.(task.id); - }} - onOpenFullPage={onOpenFullPage} - /> - + 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({ void; onSelectRange?: (taskId: string, orderedIds: string[]) => void; isMultiSelectMode?: boolean; + externalLinkAvailability: KanbanExternalLinkAvailability; }) { const taskCounts = useMemo(() => { const counts: Record = {}; @@ -244,6 +250,7 @@ function MobileKanbanLayout({ onToggleSelect={onToggleSelect} onSelectRange={onSelectRange} isMultiSelectMode={isMultiSelectMode} + externalLinkAvailability={externalLinkAvailability} /> @@ -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} /> ))} @@ -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} /> ))} @@ -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, ], ); diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 25ed0c3bb0..31a078c9aa 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -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 /> diff --git a/apps/web/components/linear/linear-link-button.tsx b/apps/web/components/linear/linear-link-button.tsx deleted file mode 100644 index 4d90bf2479..0000000000 --- a/apps/web/components/linear/linear-link-button.tsx +++ /dev/null @@ -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 ( - } - 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..." - /> - ); -} diff --git a/apps/web/components/sentry/sentry-link-button.tsx b/apps/web/components/sentry/sentry-link-button.tsx deleted file mode 100644 index a720b2af0b..0000000000 --- a/apps/web/components/sentry/sentry-link-button.tsx +++ /dev/null @@ -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 ( - } - 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..." - /> - ); -} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 2933469ad1..9f21f46b93 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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; + 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 ( @@ -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 })} /> + ); }); diff --git a/apps/web/components/task/task-external-link-dialog.test.tsx b/apps/web/components/task/task-external-link-dialog.test.tsx new file mode 100644 index 0000000000..e5e75bea89 --- /dev/null +++ b/apps/web/components/task/task-external-link-dialog.test.tsx @@ -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( + + + + + , + ); +} + +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(); + }); +}); diff --git a/apps/web/components/task/task-external-link-dialog.tsx b/apps/web/components/task/task-external-link-dialog.tsx new file mode 100644 index 0000000000..fa9124d1a0 --- /dev/null +++ b/apps/web/components/task/task-external-link-dialog.tsx @@ -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; + 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).shortId === "string" + ); +} + +const PROVIDERS: Record = { + 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(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 ( + + + + {config.title} + {config.description} + + + {config.inputLabel} + { + setInput(event.target.value); + if (error) setError(null); + }} + placeholder={config.placeholder} + disabled={submitting} + /> + {error && ( + + {error} + + )} + + + onOpenChange(false)} + disabled={submitting} + > + Cancel + + + {submitting ? "Saving" : "Save"} + + + + + ); +} diff --git a/apps/web/components/task/task-external-link-utils.test.ts b/apps/web/components/task/task-external-link-utils.test.ts new file mode 100644 index 0000000000..4f5aa81e6d --- /dev/null +++ b/apps/web/components/task/task-external-link-utils.test.ts @@ -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", + ); + }); +}); diff --git a/apps/web/components/task/task-external-link-utils.ts b/apps/web/components/task/task-external-link-utils.ts new file mode 100644 index 0000000000..bde3229ca8 --- /dev/null +++ b/apps/web/components/task/task-external-link-utils.ts @@ -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; +} diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 2307bba8dc..894e380ec2 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -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", )} > 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} /> + + > + ); +} + +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 && ( )} + {linkingExternalIssueTask && workspaceId && ( + { + if (!open) setLinkingExternalIssueTask(null); + }} + provider={linkingExternalIssueTask.provider} + task={linkingExternalIssueTask.task} + workspaceId={workspaceId} + /> + )} > ); } diff --git a/apps/web/components/task/task-session-sidebar-link-actions.ts b/apps/web/components/task/task-session-sidebar-link-actions.ts index 46248ba8aa..9501a9356b 100644 --- a/apps/web/components/task/task-session-sidebar-link-actions.ts +++ b/apps/web/components/task/task-session-sidebar-link-actions.ts @@ -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( null, ); const [linkingIssueTask, setLinkingIssueTask] = useState(null); + const [linkingExternalIssueTask, setLinkingExternalIssueTask] = + useState(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, }; } diff --git a/apps/web/components/task/task-session-sidebar-task-linking.ts b/apps/web/components/task/task-session-sidebar-task-linking.ts new file mode 100644 index 0000000000..334b0360f1 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-task-linking.ts @@ -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, + | "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, + }; +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 6368bf0673..d9fe928fd0 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -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(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} /> - + ); diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index 3cb8c97f71..b47adf45cd 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -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 & { moveTasks: ReturnType; }; -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} /> - + void) | undefined, +export function createTaskLinkSelectAction( + task: Pick, + 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, + 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 ( @@ -550,14 +591,36 @@ function TaskLinkMenu({ Link - - - GitHub Pull Request - - - - GitHub Issue - + {onLinkPullRequest && ( + + + GitHub Pull Request + + )} + {onLinkIssue && ( + + + GitHub Issue + + )} + {onLinkJiraTicket && ( + + + Jira Ticket + + )} + {onLinkLinearIssue && ( + + + Linear Issue + + )} + {onLinkSentryIssue && ( + + + Sentry Issue + + )} ); diff --git a/apps/web/components/task/task-switcher.test.tsx b/apps/web/components/task/task-switcher.test.tsx index 577171ff5b..da88c8f266 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -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( + + [0]>)} + /> + , + ); + + 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(); + }); +}); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 86e5bad10f..851e2f3b60 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -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) { + 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} diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index 1e7dd61a47..b14e9f8523 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -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, })); diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 76dc124ef0..e5523a6120 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -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({ )} ; } - return ( - <> - - - > - ); + 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({ - + > )} @@ -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({ )} { + 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 diff --git a/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts b/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts new file mode 100644 index 0000000000..7f35c531d9 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-external-link-menu.spec.ts @@ -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(); + }); +}); diff --git a/docs/specs/tasks/link-existing-task-github-issue.md b/docs/specs/tasks/link-existing-task-github-issue.md index 272918d5b4..091e7e36a5 100644 --- a/docs/specs/tasks/link-existing-task-github-issue.md +++ b/docs/specs/tasks/link-existing-task-github-issue.md @@ -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: ` 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: ` 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: ` 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.
+ {error} +