Files
kandev/apps/web/components/task-create-dialog-form-body.tsx
T
João Salavisa 7b5b3f329d feat: add Linear integration (#736)
* feat: add Linear integration

Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.

* fix(linear): address PR review feedback

- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md

* fix(linear): address second-round PR review feedback

- Round Linear's fractional state position instead of truncating it via
  int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
  previous workspace's config / form / testResult / teams cannot bleed into
  the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
  and "legacy" so a regression to strict value === "true" would fail

* fix(linear): address third-round PR review feedback

- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
  GetIssue instead of swallowing them — a caller cancellation must not return
  a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
  strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
  vs workspaceId-as-query-param so accidentally swapping them would fail

* test(linear): extract repeated literals in linear-api.test.ts

Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.

---------

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

420 lines
13 KiB
TypeScript

"use client";
import { memo, useCallback, useState } from "react";
import Link from "next/link";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import { WorkflowSelectorRow } from "@/components/workflow-selector-row";
import { AgentLogo } from "@/components/agent-logo";
import type { DialogFormState } from "@/components/task-create-dialog-types";
import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { TaskFormInputs } from "@/components/task-create-dialog-selectors";
import {
FreshBranchToggle,
computeBranchPlaceholder,
} from "@/components/task-create-dialog-fresh-branch";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
type SelectorOption = {
value: string;
label: string;
renderLabel: () => React.ReactNode;
};
type CreateEditSelectorsProps = {
isTaskStarted: boolean;
hasRepositorySelection: boolean;
branchOptions: SelectorOption[];
branch: string;
onBranchChange: (value: string) => void;
branchesLoading: boolean;
localBranchesLoading: boolean;
agentProfiles: AgentProfileOption[];
agentProfilesLoading: boolean;
agentProfileOptions: SelectorOption[];
agentProfileId: string;
onAgentProfileChange: (value: string) => void;
isCreatingSession: boolean;
executorProfileOptions: Array<{
value: string;
label: string;
renderLabel?: () => React.ReactNode;
}>;
executorProfileId: string;
onExecutorProfileChange: (value: string) => void;
executorsLoading: boolean;
BranchSelectorComponent: React.ComponentType<{
options: SelectorOption[];
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
searchPlaceholder: string;
emptyMessage: string;
}>;
AgentSelectorComponent: React.ComponentType<{
options: SelectorOption[];
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
}>;
ExecutorProfileSelectorComponent: React.ComponentType<{
options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
}>;
isLocalExecutor: boolean;
useGitHubUrl: boolean;
workflowAgentLocked: boolean;
freshBranchEnabled: boolean;
onToggleFreshBranch: (enabled: boolean) => void;
currentLocalBranch: string;
};
type AgentColumnProps = Pick<
CreateEditSelectorsProps,
| "agentProfiles"
| "agentProfilesLoading"
| "agentProfileOptions"
| "agentProfileId"
| "onAgentProfileChange"
| "isCreatingSession"
| "AgentSelectorComponent"
| "workflowAgentLocked"
>;
function AgentColumn({
agentProfiles,
agentProfilesLoading,
agentProfileOptions,
agentProfileId,
onAgentProfileChange,
isCreatingSession,
AgentSelectorComponent,
workflowAgentLocked,
}: AgentColumnProps) {
if (agentProfiles.length === 0 && !agentProfilesLoading) {
return (
<div className="flex h-7 items-center justify-center gap-2 rounded-sm border border-input px-3 text-xs text-muted-foreground">
<span>No agents found.</span>
<Link href="/settings/agents" className="cursor-pointer text-primary hover:underline">
Add agent
</Link>
</div>
);
}
const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent";
return (
<>
<AgentSelectorComponent
options={agentProfileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
placeholder={placeholder}
disabled={agentProfilesLoading || isCreatingSession || workflowAgentLocked}
/>
{workflowAgentLocked && (
<p className="text-[11px] text-muted-foreground mt-1">Agent set by workflow</p>
)}
</>
);
}
export const CreateEditSelectors = memo(function CreateEditSelectors(
props: CreateEditSelectorsProps,
) {
if (props.isTaskStarted) return null;
const {
hasRepositorySelection,
branchOptions,
branch,
onBranchChange,
branchesLoading,
localBranchesLoading,
executorProfileOptions,
executorProfileId,
onExecutorProfileChange,
executorsLoading,
isLocalExecutor,
useGitHubUrl,
freshBranchEnabled,
onToggleFreshBranch,
currentLocalBranch,
BranchSelectorComponent,
ExecutorProfileSelectorComponent,
} = props;
const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl;
const lockedToCurrentBranch = isLocalWithoutGitHubUrl && !freshBranchEnabled;
const branchPlaceholder = computeBranchPlaceholder({
lockedToCurrentBranch,
currentLocalBranch,
hasRepositorySelection,
loading: branchesLoading || localBranchesLoading,
optionCount: branchOptions.length,
});
const branchDisabled =
lockedToCurrentBranch ||
!hasRepositorySelection ||
branchesLoading ||
localBranchesLoading ||
branchOptions.length === 0;
// When the local executor locks to the current branch, ignore any branch
// value the user picked under a different executor — the placeholder
// should reflect the actual checked-out branch instead.
const branchValue = lockedToCurrentBranch ? "" : branch;
return (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 flex-1">
<BranchSelectorComponent
options={branchOptions}
value={branchValue}
onValueChange={onBranchChange}
placeholder={branchPlaceholder}
searchPlaceholder="Search branches..."
emptyMessage="No branch found."
disabled={branchDisabled}
/>
</div>
{isLocalWithoutGitHubUrl && (
<FreshBranchToggle enabled={freshBranchEnabled} onToggle={onToggleFreshBranch} />
)}
</div>
<div>
<AgentColumn {...props} />
</div>
<div>
<ExecutorProfileSelectorComponent
options={executorProfileOptions}
value={executorProfileId}
onValueChange={onExecutorProfileChange}
placeholder={executorsLoading ? "Loading profiles..." : "Select profile"}
disabled={executorsLoading}
/>
</div>
</div>
);
});
type SessionSelectorsProps = {
agentProfileOptions: SelectorOption[];
agentProfileId: string;
onAgentProfileChange: (value: string) => void;
agentProfilesLoading: boolean;
isCreatingSession: boolean;
executorProfileOptions: Array<{
value: string;
label: string;
renderLabel?: () => React.ReactNode;
}>;
executorProfileId: string;
onExecutorProfileChange: (value: string) => void;
executorsLoading: boolean;
AgentSelectorComponent: React.ComponentType<{
options: SelectorOption[];
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
}>;
ExecutorProfileSelectorComponent: React.ComponentType<{
options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>;
value: string;
onValueChange: (value: string) => void;
disabled: boolean;
placeholder: string;
triggerClassName?: string;
}>;
};
export const SessionSelectors = memo(function SessionSelectors({
agentProfileOptions,
agentProfileId,
onAgentProfileChange,
agentProfilesLoading,
isCreatingSession,
executorProfileOptions,
executorProfileId,
onExecutorProfileChange,
executorsLoading,
AgentSelectorComponent,
ExecutorProfileSelectorComponent,
}: SessionSelectorsProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<AgentSelectorComponent
options={agentProfileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
placeholder={agentProfilesLoading ? "Loading agent profiles..." : "Select agent profile"}
disabled={agentProfilesLoading || isCreatingSession}
/>
<ExecutorProfileSelectorComponent
options={executorProfileOptions}
value={executorProfileId}
onValueChange={onExecutorProfileChange}
placeholder={executorsLoading ? "Loading profiles..." : "Select profile"}
disabled={executorsLoading || isCreatingSession}
/>
</div>
);
});
type WorkflowSectionProps = {
isCreateMode: boolean;
isTaskStarted: boolean;
workflows: Array<{ id: string; name: string; agent_profile_id?: string; [key: string]: unknown }>;
snapshots: Record<string, WorkflowSnapshotData>;
effectiveWorkflowId: string | null;
onWorkflowChange: (value: string) => void;
agentProfiles: AgentProfileOption[];
};
export const WorkflowSection = memo(function WorkflowSection({
isCreateMode,
isTaskStarted,
workflows,
snapshots,
effectiveWorkflowId,
onWorkflowChange,
agentProfiles,
}: WorkflowSectionProps) {
const [lastUsedWorkflowId, setLastUsedWorkflowId] = useState<string | null>(null);
const handleWorkflowChange = useCallback(
(workflowId: string) => {
setLastUsedWorkflowId(workflowId);
onWorkflowChange(workflowId);
},
[onWorkflowChange],
);
if (!isCreateMode || isTaskStarted) return null;
// Single workflow — show agent override info if any overrides exist
if (workflows.length <= 1) {
const singleWorkflow = workflows[0];
if (!singleWorkflow) return null;
const snapshot = snapshots[singleWorkflow.id];
const workflowProfile = singleWorkflow.agent_profile_id
? agentProfiles.find((p) => p.id === singleWorkflow.agent_profile_id)
: null;
const stepsWithOverrides = (snapshot?.steps ?? [])
.filter((s) => s.agent_profile_id)
.map((s) => ({
name: s.title,
profile: agentProfiles.find((p) => p.id === s.agent_profile_id),
}))
.filter((s) => s.profile);
if (!workflowProfile && stepsWithOverrides.length === 0) return null;
return (
<div
className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
data-testid="workflow-override-info"
>
{workflowProfile && (
<span className="flex items-center gap-1">
<AgentLogo agentName={workflowProfile.agent_name} size={14} className="shrink-0" />
<span>{workflowProfile.label}</span>
</span>
)}
{stepsWithOverrides.map((s) => (
<span key={s.name} className="flex items-center gap-1">
<span className="text-muted-foreground/50">{s.name}:</span>
<AgentLogo agentName={s.profile!.agent_name} size={14} className="shrink-0" />
<span>{s.profile!.label}</span>
</span>
))}
</div>
);
}
return (
<WorkflowSelectorRow
workflows={workflows}
snapshots={snapshots}
selectedWorkflowId={effectiveWorkflowId ?? null}
onWorkflowChange={handleWorkflowChange}
lastUsedWorkflowId={lastUsedWorkflowId}
agentProfiles={agentProfiles}
/>
);
});
export type DialogPromptSectionProps = {
isSessionMode: boolean;
isTaskStarted: boolean;
isPassthroughProfile: boolean;
initialDescription: string;
hasDescription: boolean;
fs: DialogFormState;
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
workspaceId?: string | null;
onJiraImport?: (ticket: JiraTicket) => void;
onLinearImport?: (issue: LinearIssue) => void;
};
// importBindings collapses the optional Jira/Linear import callbacks into the
// shape TaskFormInputs expects, dropping integrations that aren't applicable
// (session mode, started tasks, or no callback wired). Keeps DialogPromptSection
// below the cyclomatic-complexity bar.
function importBindings<T>(
enabled: boolean,
workspaceId: string | null,
isPassthroughProfile: boolean,
onImport: ((value: T) => void) | undefined,
) {
if (!enabled || !onImport) return undefined;
return { workspaceId, disabled: isPassthroughProfile, onImport };
}
export function DialogPromptSection({
isSessionMode,
isTaskStarted,
isPassthroughProfile,
initialDescription,
hasDescription,
fs,
handleKeyDown,
enhance,
workspaceId,
onJiraImport,
onLinearImport,
}: DialogPromptSectionProps) {
const importsEnabled = !isSessionMode && !isTaskStarted;
const ws = workspaceId ?? null;
return (
<>
<TaskFormInputs
key={fs.openCycle}
isSessionMode={isSessionMode}
autoFocus={!isTaskStarted}
initialDescription={initialDescription}
onDescriptionChange={fs.setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={fs.descriptionInputRef}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={isPassthroughProfile ? "Passthrough mode — prompt not supported" : undefined}
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
jiraImport={importBindings(importsEnabled, ws, isPassthroughProfile, onJiraImport)}
linearImport={importBindings(importsEnabled, ws, isPassthroughProfile, onLinearImport)}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
)}
</>
);
}