diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 8358490a9ba..9c0e5cf6121 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -200,6 +200,8 @@ import {
} from '@/components/linear-project-view-surfaces'
import JiraIssueWorkspace from '@/components/JiraIssueWorkspace'
import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list'
+import { LinearAssigneeCell } from '@/components/task-page-linear-assignee-cell'
+import { LinearLabelsCell } from '@/components/task-page-linear-labels-cell'
import {
getSingleJiraProjectScope,
getTaskPageJiraStatusOrderScopeKey,
@@ -11483,7 +11485,6 @@ export default function TaskPage(): React.JSX.Element {
{section.issues.map((issue) => {
const selected = issue.id === selectedLinearIssueId
- const labels = issue.labels.slice(0, 2)
const dragging = linearBoardDraggingIssueId === issue.id
const updating = linearBoardUpdatingIssueIds.has(issue.id)
const teamLabel =
@@ -11616,13 +11617,11 @@ export default function TaskPage(): React.JSX.Element {
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
-
- {issue.assignee?.displayName ??
- translate(
- 'auto.components.TaskPage.42a9160321',
- 'Unassigned'
- )}
-
+
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
{teamLabel}
@@ -11637,22 +11636,14 @@ export default function TaskPage(): React.JSX.Element {
) : null}
- {effectiveLinearDisplayProperties.has('labels') &&
- issue.labels.length > 0 ? (
+ {effectiveLinearDisplayProperties.has('labels') ? (
- {labels.map((label) => (
-
- {label}
-
- ))}
- {issue.labels.length > labels.length ? (
-
- +{issue.labels.length - labels.length}
-
- ) : null}
+
) : null}
@@ -11684,7 +11675,6 @@ export default function TaskPage(): React.JSX.Element {
const issue = row.issue
const selected = issue.id === selectedLinearIssueId
- const labels = issue.labels.slice(0, 3)
const teamLabel =
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
@@ -11748,10 +11738,12 @@ export default function TaskPage(): React.JSX.Element {
/>
) : null}
{effectiveLinearDisplayProperties.has('assignee') ? (
-
- {issue.assignee?.displayName ??
- translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
-
+
) : null}
{effectiveLinearDisplayProperties.has('team') ? (
@@ -11769,19 +11761,11 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('labels') ? (
- {labels.map((label) => (
-
- {label}
-
- ))}
- {issue.labels.length > labels.length ? (
-
- +{issue.labels.length - labels.length}
-
- ) : null}
+
) : null}
@@ -11803,31 +11787,11 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('assignee') ? (
-
-
-
- {issue.assignee?.avatarUrl ? (
-

- ) : (
- (issue.assignee?.displayName?.slice(0, 1) ?? '-')
- )}
-
-
-
- {issue.assignee?.displayName ??
- translate('auto.components.TaskPage.42a9160321', 'Unassigned')}
-
-
+
) : null}
diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
index 5c9b44b5ac1..303ebfbb63d 100644
--- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
+++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts
@@ -190,6 +190,22 @@ describe('feature interaction writer boundaries', () => {
expect(section).toContain(linearWriter)
}
+ const linearCellSections = [
+ sourceBetween(
+ componentSource('task-page-linear-assignee-cell.tsx'),
+ 'export function LinearAssigneeCell',
+ 'return ('
+ ),
+ sourceBetween(
+ componentSource('task-page-linear-labels-cell.tsx'),
+ 'export function LinearLabelsCell',
+ 'return ('
+ )
+ ]
+ for (const section of linearCellSections) {
+ expect(section).toContain(linearWriter)
+ }
+
const drawerMutationSections = [
sourceBetween(drawerSource, 'const handleStateChange', 'const handlePriorityChange'),
sourceBetween(drawerSource, 'const handlePriorityChange', 'const handleEstimateChange'),
diff --git a/src/renderer/src/components/task-page-linear-assignee-cell.tsx b/src/renderer/src/components/task-page-linear-assignee-cell.tsx
new file mode 100644
index 00000000000..3cb327d1c13
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-assignee-cell.tsx
@@ -0,0 +1,222 @@
+import React, { useCallback, useRef, useState } from 'react'
+import { ChevronDown, LoaderCircle } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { useTeamMembers } from '@/hooks/useIssueMetadata'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
+import { useAppStore } from '@/store'
+import type { TaskSourceContext } from '../../../shared/task-source-context'
+import type { LinearIssue } from '../../../shared/types'
+
+export function LinearAssigneeCell({
+ issue,
+ className,
+ sourceContext,
+ variant = 'avatar'
+}: {
+ issue: LinearIssue
+ className?: string
+ sourceContext?: TaskSourceContext | null
+ variant?: 'avatar' | 'name'
+}): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const providerSettings = sourceContext ?? settings
+ const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
+ const members = useTeamMembers(issue.team.id, providerSettings, issue.workspaceId)
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+ const reqRef = useRef(0)
+
+ const handleAssigneeChange = useCallback(
+ (memberId: string | null) => {
+ const nextAssigneeId = memberId
+ if ((issue.assignee?.id ?? null) === nextAssigneeId || pending) {
+ return
+ }
+
+ const member =
+ nextAssigneeId == null ? undefined : members.data.find((m) => m.id === nextAssigneeId)
+ if (nextAssigneeId != null && !member) {
+ return
+ }
+
+ reqRef.current += 1
+ const reqId = reqRef.current
+ const previousAssignee = issue.assignee
+ const nextAssignee = member
+ ? { id: member.id, displayName: member.displayName, avatarUrl: member.avatarUrl }
+ : undefined
+
+ setPending(true)
+ patchLinearIssue(issue.id, { assignee: nextAssignee }, { sourceContext })
+ void linearUpdateIssue(
+ providerSettings,
+ issue.id,
+ { assigneeId: nextAssigneeId },
+ issue.workspaceId
+ )
+ .then((result) => {
+ if (reqId !== reqRef.current) {
+ return
+ }
+ if (result.ok === false) {
+ patchLinearIssue(issue.id, { assignee: previousAssignee }, { sourceContext })
+ toast.error(
+ result.error ??
+ translate(
+ 'auto.components.TaskPage.linearAssigneeUpdateFailed',
+ 'Failed to update Linear assignee'
+ )
+ )
+ return
+ }
+ useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
+ useAppStore.getState().recordFeatureInteraction('linear-tasks')
+ })
+ .catch(() => {
+ if (reqId !== reqRef.current) {
+ return
+ }
+ patchLinearIssue(issue.id, { assignee: previousAssignee }, { sourceContext })
+ toast.error(
+ translate(
+ 'auto.components.TaskPage.linearAssigneeUpdateFailed',
+ 'Failed to update Linear assignee'
+ )
+ )
+ })
+ .finally(() => {
+ if (reqId === reqRef.current) {
+ setPending(false)
+ }
+ })
+ },
+ [
+ issue.assignee,
+ issue.id,
+ issue.workspaceId,
+ members.data,
+ patchLinearIssue,
+ pending,
+ providerSettings,
+ sourceContext
+ ]
+ )
+
+ const unassignedLabel = translate('auto.components.TaskPage.42a9160321', 'Unassigned')
+ const displayName = issue.assignee?.displayName ?? unassignedLabel
+ const triggerContent =
+ variant === 'name' ? (
+ {displayName}
+ ) : issue.assignee ? (
+ issue.assignee.avatarUrl ? (
+
+ ) : (
+
+ {issue.assignee.displayName.slice(0, 1)}
+
+ )
+ ) : (
+ -
+ )
+
+ return (
+
+
+
+
+ e.stopPropagation()}
+ >
+
+ {members.error ? (
+ {members.error}
+ ) : members.loading ? (
+
+
+ {translate('auto.components.TaskPage.linearLoadingMembers', 'Loading members')}
+
+ ) : members.data.length > 0 ? (
+ members.data.map((member) => (
+
+ ))
+ ) : (
+
+ {translate('auto.components.TaskPage.linearNoMembersFound', 'No members found')}
+
+ )}
+
+
+ )
+}
diff --git a/src/renderer/src/components/task-page-linear-labels-cell.tsx b/src/renderer/src/components/task-page-linear-labels-cell.tsx
new file mode 100644
index 00000000000..cda8375e1fd
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-labels-cell.tsx
@@ -0,0 +1,232 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react'
+import { Check, ChevronDown, LoaderCircle, Tag } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { useTeamLabels } from '@/hooks/useIssueMetadata'
+import { translate } from '@/i18n/i18n'
+import { cn } from '@/lib/utils'
+import { linearUpdateIssue } from '@/runtime/runtime-linear-client'
+import { useAppStore } from '@/store'
+import type { TaskSourceContext } from '../../../shared/task-source-context'
+import type { LinearIssue } from '../../../shared/types'
+
+export function LinearLabelsCell({
+ issue,
+ className,
+ sourceContext,
+ maxVisible = 3
+}: {
+ issue: LinearIssue
+ className?: string
+ sourceContext?: TaskSourceContext | null
+ maxVisible?: number
+}): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const providerSettings = sourceContext ?? settings
+ const patchLinearIssue = useAppStore((s) => s.patchLinearIssue)
+ const teamLabels = useTeamLabels(issue.team.id, providerSettings, issue.workspaceId)
+ const [open, setOpen] = useState(false)
+ const [pending, setPending] = useState(false)
+ const reqRef = useRef(0)
+
+ const selectedIds = issue.labelIds
+ const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
+ const visibleLabels = issue.labels.slice(0, maxVisible)
+
+ const handleLabelToggle = useCallback(
+ (labelId: string) => {
+ if (pending) {
+ return
+ }
+
+ const isRemoving = selectedIdSet.has(labelId)
+ const nextLabelIds = isRemoving
+ ? selectedIds.filter((id) => id !== labelId)
+ : [...selectedIds, labelId]
+
+ const labelNameById = new Map(teamLabels.data.map((label) => [label.id, label.name] as const))
+ for (let i = 0; i < issue.labelIds.length; i += 1) {
+ const id = issue.labelIds[i]
+ const name = issue.labels[i]
+ if (id && name && !labelNameById.has(id)) {
+ labelNameById.set(id, name)
+ }
+ }
+
+ const nextLabels = nextLabelIds
+ .map((id) => labelNameById.get(id))
+ .filter((name): name is string => name != null && name.length > 0)
+
+ reqRef.current += 1
+ const reqId = reqRef.current
+ const previousLabelIds = issue.labelIds
+ const previousLabels = issue.labels
+
+ setPending(true)
+ patchLinearIssue(issue.id, { labelIds: nextLabelIds, labels: nextLabels }, { sourceContext })
+ void linearUpdateIssue(
+ providerSettings,
+ issue.id,
+ { labelIds: nextLabelIds },
+ issue.workspaceId
+ )
+ .then((result) => {
+ if (reqId !== reqRef.current) {
+ return
+ }
+ if (result.ok === false) {
+ patchLinearIssue(
+ issue.id,
+ { labelIds: previousLabelIds, labels: previousLabels },
+ { sourceContext }
+ )
+ toast.error(
+ result.error ??
+ translate(
+ 'auto.components.TaskPage.linearLabelsUpdateFailed',
+ 'Failed to update Linear labels'
+ )
+ )
+ return
+ }
+ useAppStore.getState().invalidateLinearIssueLists({ sourceContext })
+ useAppStore.getState().recordFeatureInteraction('linear-tasks')
+ })
+ .catch(() => {
+ if (reqId !== reqRef.current) {
+ return
+ }
+ patchLinearIssue(
+ issue.id,
+ { labelIds: previousLabelIds, labels: previousLabels },
+ { sourceContext }
+ )
+ toast.error(
+ translate(
+ 'auto.components.TaskPage.linearLabelsUpdateFailed',
+ 'Failed to update Linear labels'
+ )
+ )
+ })
+ .finally(() => {
+ if (reqId === reqRef.current) {
+ setPending(false)
+ }
+ })
+ },
+ [
+ issue.id,
+ issue.labelIds,
+ issue.labels,
+ issue.workspaceId,
+ patchLinearIssue,
+ pending,
+ providerSettings,
+ selectedIdSet,
+ selectedIds,
+ sourceContext,
+ teamLabels.data
+ ]
+ )
+
+ return (
+
+
+
+
+ e.stopPropagation()}
+ >
+ {teamLabels.error ? (
+
+ {teamLabels.error}
+
+ ) : teamLabels.loading ? (
+
+
+ {translate('auto.components.TaskPage.linearLoadingLabels', 'Loading labels')}
+
+ ) : teamLabels.data.length > 0 ? (
+ teamLabels.data.map((label) => {
+ const isOn = selectedIdSet.has(label.id)
+ return (
+
+ )
+ })
+ ) : (
+
+ {translate('auto.components.TaskPage.linearNoLabelsFound', 'No labels found')}
+
+ )}
+
+
+ )
+}