From eebaf47df0fbaef3090f19eb522d39b3a2ed6cb1 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Tue, 4 Aug 2026 22:12:00 -0700
Subject: [PATCH] Add 'Has Workspace' mode to show Linear issues linked to
local worktrees (#12632)
* feat(linear): add 'Has Workspace' mode to show issues linked to local wo
Enable users to view and open existing workspaces attached to Linear issues
instead of accidentally starting duplicates. Includes shared worktree attachment
labeling for consistent UX across GitHub and Linear surfaces.
* fix(linear): apply search filter in 'in-orca' mode to prevent drops
- Apply search filter in 'in-orca' mode even without active context label to prevent
team filters from silently hiding linked tickets (no "Fetch more" recovery path)
- Add aria-label to workspace-open button for accessibility
- Update tooltip from "local worktree" to "Orca workspace"
- Reorganize i18n: move workspace.open from lib.linear to components.issue
- Expand test coverage for workspace start and activation scenarios
* fix(linear): avoid mutating in-orca linked refs during render
React Doctor fails static analysis when refs are written during render.
Keep the latest linked refs in an effect so the in-orca loader can still
read them without re-running on identity-only worktree churn.
---
.../rpc/methods/task-resume-state-schema.ts | 2 +-
.../src/components/LinearIssueWorkspace.tsx | 143 +++++-
.../src/components/LinearItemDrawer.tsx | 117 ++++-
src/renderer/src/components/TaskPage.tsx | 428 +++++++++++++++---
.../task-page-linear-in-orca-issues.test.ts | 189 ++++++++
.../task-page-linear-in-orca-issues.ts | 178 ++++++++
.../task-page-localized-options.tsx | 8 +-
src/renderer/src/i18n/locales/en.json | 31 +-
src/renderer/src/i18n/locales/es.json | 29 +-
src/renderer/src/i18n/locales/ja.json | 29 +-
src/renderer/src/i18n/locales/ko.json | 29 +-
src/renderer/src/i18n/locales/zh.json | 29 +-
.../github-work-item-workspace-attachment.ts | 29 +-
.../linear-issue-workspace-attachment.test.ts | 200 ++++++++
.../lib/linear-issue-workspace-attachment.ts | 130 ++++++
.../lib/linear-issue-workspace-open.test.ts | 120 +++++
.../src/lib/linear-issue-workspace-open.ts | 56 +++
.../src/lib/worktree-attachment-label.ts | 31 ++
src/renderer/src/store/slices/ui.ts | 3 +-
src/shared/types.ts | 2 +-
20 files changed, 1645 insertions(+), 138 deletions(-)
create mode 100644 src/renderer/src/components/task-page-linear-in-orca-issues.test.ts
create mode 100644 src/renderer/src/components/task-page-linear-in-orca-issues.ts
create mode 100644 src/renderer/src/lib/linear-issue-workspace-attachment.test.ts
create mode 100644 src/renderer/src/lib/linear-issue-workspace-attachment.ts
create mode 100644 src/renderer/src/lib/linear-issue-workspace-open.test.ts
create mode 100644 src/renderer/src/lib/linear-issue-workspace-open.ts
create mode 100644 src/renderer/src/lib/worktree-attachment-label.ts
diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts
index 825248ac4..f0a8e814e 100644
--- a/src/main/runtime/rpc/methods/task-resume-state-schema.ts
+++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts
@@ -9,7 +9,7 @@ export const TaskResumeState = z
githubItemsPreset: z.string().nullable().optional(),
githubItemsQuery: z.string().optional(),
githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(),
- linearMode: z.enum(['issues', 'projects', 'views']).optional(),
+ linearMode: z.enum(['issues', 'projects', 'views', 'in-orca']).optional(),
linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(),
linearQuery: z.string().optional(),
linearContext: z
diff --git a/src/renderer/src/components/LinearIssueWorkspace.tsx b/src/renderer/src/components/LinearIssueWorkspace.tsx
index ccb1f03c0..690f2d426 100644
--- a/src/renderer/src/components/LinearIssueWorkspace.tsx
+++ b/src/renderer/src/components/LinearIssueWorkspace.tsx
@@ -11,6 +11,7 @@ import {
ChevronRight,
Clipboard,
FolderKanban,
+ FolderOpen,
GitBranch,
Link,
LoaderCircle,
@@ -31,15 +32,29 @@ import {
type LinearLocalComment
} from '@/components/LinearItemDrawer'
import { Button } from '@/components/ui/button'
+import { ButtonGroup } from '@/components/ui/button-group'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { buildLinearIssueContextSnapshot } from '@/lib/linear-issue-context-snapshot'
+import {
+ findLinearIssueWorkspaceAttachment,
+ getLinearIssueWorkspaceAttachmentLabel
+} from '@/lib/linear-issue-workspace-attachment'
+import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open'
+import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
import { buildContainedLinkedContextBlock } from '@/lib/linked-work-item-context'
import { useMountedRef } from '@/hooks/useMountedRef'
import { useAppStore } from '@/store'
+import { useAllWorktrees } from '@/store/selectors'
import {
buildLinearIssueBranchName,
formatLinearIssueRelativeTime
@@ -512,6 +527,12 @@ export default function LinearIssueWorkspace({
}: LinearIssueWorkspaceProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const providerSettings = sourceContext ?? settings
+ const allWorktrees = useAllWorktrees()
+ const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
+ const attachmentWorkspaces = useMemo(
+ () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)],
+ [allWorktrees, folderWorkspaces]
+ )
const [fullIssue, setFullIssue] = useState(null)
const [issueLoading, setIssueLoading] = useState(false)
const [comments, setComments] = useState([])
@@ -647,6 +668,14 @@ export default function LinearIssueWorkspace({
const displayed = fullIssue ?? issue
+ const attachedWorkspace = useMemo(
+ () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null),
+ [attachmentWorkspaces, displayed]
+ )
+ const attachedWorkspaceLabel = attachedWorkspace
+ ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace)
+ : null
+
const handleUseIssue = useCallback((): void => {
if (!displayed) {
return
@@ -654,6 +683,13 @@ export default function LinearIssueWorkspace({
onUse(displayed)
}, [displayed, onUse])
+ const handleOpenOrUseIssue = useCallback((): void => {
+ if (!displayed) {
+ return
+ }
+ openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed))
+ }, [displayed, onUse])
+
const handleCommentAdded = useCallback((comment: LinearLocalComment) => {
const newComment: LinearComment = {
id: comment.id || createBrowserUuid(),
@@ -779,24 +815,68 @@ export default function LinearIssueWorkspace({
{translate('auto.components.LinearIssueWorkspace.30c1242f3a', 'Copy identifier')}
-
-
-
-
-
-
-
- {translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')}
-
-
+ {attachedWorkspace ? (
+
+
+
+
+ {translate(
+ 'auto.components.LinearIssueWorkspace.openWorkspace',
+ 'Open workspace'
+ )}
+
+
+
+
+
+
+
+
+
+
+ {translate(
+ 'auto.components.LinearIssueWorkspace.startNewWorkspace',
+ 'Start new workspace'
+ )}
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ {translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')}
+
+
+ )}
{variant === 'sheet' ? (
@@ -949,6 +1029,33 @@ export default function LinearIssueWorkspace({
onProjectChanged={handleProjectChanged}
sourceContext={sourceContext}
/>
+
+
+
+ {translate('auto.components.LinearIssueWorkspace.workspaceSection', 'Workspace')}
+
+
+
+ {attachedWorkspaceLabel ? (
+
+
+ {attachedWorkspaceLabel}
+
+ ) : (
+
+ {translate('auto.components.LinearIssueWorkspace.noWorkspaceYet', 'None yet')}
+
+ )}
+
+
diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx
index 719aecbf4..82053896d 100644
--- a/src/renderer/src/components/LinearItemDrawer.tsx
+++ b/src/renderer/src/components/LinearItemDrawer.tsx
@@ -1,12 +1,14 @@
/* eslint-disable max-lines -- Why: the Linear drawer co-locates read-only preview, edit controls, and comment input so the full issue surface stays in one file. */
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: Linear drawer state hydrates full issue details and comments from provider IPC for the selected issue. */
-import React, { useCallback, useEffect, useRef, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowRight,
ChevronDown,
ExternalLink,
+ FolderOpen,
Gauge,
LoaderCircle,
+ Plus,
Send,
Tag,
UserRound,
@@ -15,6 +17,13 @@ import {
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
+import { ButtonGroup } from '@/components/ui/button-group'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { LinearIssueTextEditor } from '@/components/LinearIssueTextEditor'
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'
@@ -27,7 +36,14 @@ import {
getCommentBodySubmitState,
hasBoundedCommentBodyText
} from '@/lib/comment-body-submit-state'
+import {
+ findLinearIssueWorkspaceAttachment,
+ getLinearIssueWorkspaceAttachmentLabel
+} from '@/lib/linear-issue-workspace-attachment'
+import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open'
+import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
import { useAppStore } from '@/store'
+import { useAllWorktrees } from '@/store/selectors'
import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut'
import { createBrowserUuid } from '@/lib/browser-uuid'
import {
@@ -1234,6 +1250,12 @@ export default function LinearItemDrawer({
const optimisticCommentsRef = useRef([])
const settings = useAppStore((s) => s.settings)
const providerSettings = sourceContext ?? settings
+ const allWorktrees = useAllWorktrees()
+ const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
+ const attachmentWorkspaces = useMemo(
+ () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)],
+ [allWorktrees, folderWorkspaces]
+ )
const handleEditStateChange = useCallback((patch: Partial) => {
hasEditedRef.current = true
@@ -1357,6 +1379,20 @@ export default function LinearItemDrawer({
}, [])
const displayed = fullIssue ?? issue
+ const attachedWorkspace = useMemo(
+ () => (displayed ? findLinearIssueWorkspaceAttachment(attachmentWorkspaces, displayed) : null),
+ [attachmentWorkspaces, displayed]
+ )
+ const attachedWorkspaceLabel = attachedWorkspace
+ ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace)
+ : null
+
+ const handleOpenOrUseIssue = useCallback((): void => {
+ if (!displayed) {
+ return
+ }
+ openLinearIssueWorkspaceOrStart(displayed, () => onUse(displayed))
+ }, [displayed, onUse])
return (
!open && onClose()}>
@@ -1526,28 +1562,75 @@ export default function LinearItemDrawer({
- {/* Comment footer + Start workspace */}
+ {/* Comment footer + Start/Open workspace */}
-
-
onUse(displayed)}
- className="w-full justify-center gap-2"
- aria-label={translate(
- 'auto.components.LinearItemDrawer.04008e6c46',
- 'Start workspace from issue'
- )}
- >
- {translate(
- 'auto.components.LinearItemDrawer.04008e6c46',
- 'Start workspace from issue'
- )}
-
-
+
+ {attachedWorkspaceLabel ? (
+
+
+ {attachedWorkspaceLabel}
+
+ ) : null}
+ {attachedWorkspace ? (
+
+
+
+
+ {translate(
+ 'auto.components.LinearItemDrawer.openWorkspace',
+ 'Open workspace'
+ )}
+
+
+
+
+
+
+
+
+ onUse(displayed)}>
+
+ {translate(
+ 'auto.components.LinearItemDrawer.startNewWorkspace',
+ 'Start new workspace'
+ )}
+
+
+
+ ) : (
+
+ {translate(
+ 'auto.components.LinearItemDrawer.04008e6c46',
+ 'Start workspace from issue'
+ )}
+
+
+ )}
)}
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index 0f2fa5c12..2a998577f 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -32,6 +32,7 @@ import {
Users,
X,
FolderKanban,
+ FolderOpen,
Tag,
UserRound
} from 'lucide-react'
@@ -138,6 +139,13 @@ import {
findGithubWorkItemWorkspaceAttachment,
getGithubWorkItemWorkspaceAttachmentLabel
} from '@/lib/github-work-item-workspace-attachment'
+import {
+ buildLinearIssueWorkspaceAttachmentIndex,
+ findLinearIssueWorkspaceAttachmentInIndex,
+ getLinearIssueWorkspaceAttachmentLabel
+} from '@/lib/linear-issue-workspace-attachment'
+import { openLinearIssueWorkspaceOrStart } from '@/lib/linear-issue-workspace-open'
+import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
import { createGitHubWorkItemWorkspaceInBackground } from '@/lib/github-work-item-background-create'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata'
@@ -228,6 +236,13 @@ import {
resolveLinearIssueEmptyKind,
shouldOfferLinearIssueFetchMore
} from '@/components/task-page-linear-issue-empty-state'
+import {
+ collectLinkedLinearIssueRefsFromWorktrees,
+ filterLinearIssuesBySearchQuery,
+ filterLinearIssuesForInOrcaWorkspace,
+ linkedLinearIssueRefsSignature,
+ readLinkedLinearIssuesWithLimit
+} from '@/components/task-page-linear-in-orca-issues'
import {
emptyLinearIssueAttributeFilter,
linearIssueAttributeFilterSignature,
@@ -936,6 +951,7 @@ function getLinearIssueGridTemplate(visibleProperties: ReadonlySet s.searchLinearIssues)
const listLinearIssues = useAppStore((s) => s.listLinearIssues)
const linearListInvalidationToken = useAppStore((s) => s.linearListInvalidationToken)
+ const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const invalidateLinearIssueLists = useAppStore((s) => s.invalidateLinearIssueLists)
const getCachedLinearIssues = useAppStore((s) => s.getCachedLinearIssues)
+ const fetchLinearIssue = useAppStore((s) => s.fetchLinearIssue)
+ const refreshLinearIssue = useAppStore((s) => s.refreshLinearIssue)
const getCachedLinearTeams = useAppStore((s) => s.getCachedLinearTeams)
const listLinearTeams = useAppStore((s) => s.listLinearTeams)
const getCachedLinearProjects = useAppStore((s) => s.getCachedLinearProjects)
@@ -5196,16 +5215,55 @@ export default function TaskPage(): React.JSX.Element {
const showLinearAttributeFilters =
linearMode === 'issues' && !activeLinearIssueContextLabel && !linearSearchActive
+ // Why: one pass over worktrees per list render; per-row scans re-parsed every link.
+ const linearAttachmentWorkspaces = useMemo(
+ () => [...allWorktrees, ...folderWorkspaces.map(folderWorkspaceToWorktree)],
+ [allWorktrees, folderWorkspaces]
+ )
+ const linearIssueAttachmentIndex = useMemo(
+ () => buildLinearIssueWorkspaceAttachmentIndex(linearAttachmentWorkspaces),
+ [linearAttachmentWorkspaces]
+ )
+ const inOrcaLinkedLinearRefs = useMemo(
+ () =>
+ collectLinkedLinearIssueRefsFromWorktrees(linearAttachmentWorkspaces, {
+ workspaceId: selectedLinearWorkspaceId,
+ workspaces: linearStatus.workspaces ?? []
+ }),
+ [linearAttachmentWorkspaces, linearStatus.workspaces, selectedLinearWorkspaceId]
+ )
+ const inOrcaLinkedLinearRefsSignature = useMemo(
+ () => linkedLinearIssueRefsSignature(inOrcaLinkedLinearRefs),
+ [inOrcaLinkedLinearRefs]
+ )
+ const inOrcaLinkedLinearRefsRef = useRef(inOrcaLinkedLinearRefs)
+ // Keep latest linked refs for the in-orca loader without re-running it on identity churn.
+ useEffect(() => {
+ inOrcaLinkedLinearRefsRef.current = inOrcaLinkedLinearRefs
+ }, [inOrcaLinkedLinearRefs])
+
const filteredLinearIssues = useMemo(() => {
- if (activeLinearIssueContextLabel) {
- return displayedLinearIssues
+ const searchedIssues =
+ linearMode === 'in-orca'
+ ? filterLinearIssuesBySearchQuery(displayedLinearIssues, appliedLinearSearch)
+ : displayedLinearIssues
+ // Why: 'in-orca' is scoped by local workspace links, not by team, and it has no "Fetch more" —
+ // a team filter would silently drop a linked ticket with no way to recover it.
+ if (activeLinearIssueContextLabel || linearMode === 'in-orca') {
+ return searchedIssues
}
// Why: team options can arrive after issue rows render; treat an empty selection as "all" until reconciliation sets teams.
- if (displayedLinearIssues.length > 0 && linearTeamSelection.size === 0) {
- return displayedLinearIssues
+ if (searchedIssues.length > 0 && linearTeamSelection.size === 0) {
+ return searchedIssues
}
- return displayedLinearIssues.filter((issue) => linearTeamSelection.has(issue.team.id))
- }, [activeLinearIssueContextLabel, displayedLinearIssues, linearTeamSelection])
+ return searchedIssues.filter((issue) => linearTeamSelection.has(issue.team.id))
+ }, [
+ activeLinearIssueContextLabel,
+ appliedLinearSearch,
+ displayedLinearIssues,
+ linearMode,
+ linearTeamSelection
+ ])
const orderedLinearIssues = useMemo(
() => [...filteredLinearIssues].sort((a, b) => compareLinearIssues(a, b, linearOrderBy)),
@@ -7484,6 +7542,108 @@ export default function TaskPage(): React.JSX.Element {
linearTaskSourceContext
])
+ // Why: Has Worktree loads Linear tickets linked on local worktrees, not a Linear list/search query.
+ useEffect(() => {
+ if (!taskResumeApplied) {
+ return
+ }
+ if (taskSource !== 'linear' || linearMode !== 'in-orca' || !linearConnected) {
+ return
+ }
+
+ let cancelled = false
+ const linkedRefs = inOrcaLinkedLinearRefsRef.current
+ const requestSignature = `in-orca::${selectedLinearWorkspaceId ?? 'default'}::${inOrcaLinkedLinearRefsSignature}`
+ const previousRequest = lastLinearRequestRef.current
+ const isNewSignature = previousRequest?.signature !== requestSignature
+ const forceRefresh = linearRefreshNonce > 0 && previousRequest?.nonce !== linearRefreshNonce
+ lastLinearRequestRef.current = { nonce: linearRefreshNonce, signature: requestSignature }
+ setLinearIssuesHasMore(false)
+ setLinearError(null)
+
+ if (linkedRefs.length === 0) {
+ setLinearIssues([])
+ setLinearLoading(false)
+ return () => {
+ cancelled = true
+ }
+ }
+
+ if (isNewSignature) {
+ setLinearIssues([])
+ }
+ setLinearLoading(true)
+ // Why: fetchLinearIssue serves anything under the 60s TTL and ignores `force`, so an
+ // explicit refresh has to go through refreshLinearIssue or the button does nothing.
+ void readLinkedLinearIssuesWithLimit(linkedRefs, (ref) => {
+ const read = forceRefresh ? refreshLinearIssue : fetchLinearIssue
+ return read(ref.identifier, ref.workspaceId ?? selectedLinearWorkspaceId, {
+ sourceContext: ref.sourceContext ?? linearTaskSourceContext
+ })
+ })
+ .then((results) => {
+ if (
+ cancelled ||
+ lastLinearRequestRef.current?.signature !== requestSignature ||
+ lastLinearRequestRef.current?.nonce !== linearRefreshNonce
+ ) {
+ return
+ }
+ const loaded = results.filter((issue): issue is LinearIssue => issue != null)
+ // Why: reads resolve to null instead of throwing, so an all-null result with links
+ // present is a load failure — not the "nothing linked yet" empty state.
+ if (loaded.length === 0) {
+ setLinearError(
+ translate(
+ 'auto.components.TaskPage.linearHasWorktreeLoadFailed',
+ 'Unable to load Linear issues linked to an Orca workspace.'
+ )
+ )
+ setLinearIssues([])
+ setLinearLoading(false)
+ return
+ }
+ if (loaded.length !== results.length) {
+ setLinearError(
+ translate(
+ 'auto.components.TaskPage.linearHasWorktreePartialLoadFailed',
+ 'Some Linear issues linked to an Orca workspace could not be loaded. Refresh to try again.'
+ )
+ )
+ }
+ setLinearIssues(filterLinearIssuesForInOrcaWorkspace(loaded, selectedLinearWorkspaceId))
+ setLinearLoading(false)
+ })
+ .catch((err) => {
+ if (
+ cancelled ||
+ lastLinearRequestRef.current?.signature !== requestSignature ||
+ lastLinearRequestRef.current?.nonce !== linearRefreshNonce
+ ) {
+ return
+ }
+ setLinearError(err instanceof Error ? err.message : 'Failed to load Linear issues.')
+ setLinearLoading(false)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ // Why: linkedRefs are read from a ref keyed by their signature, so unrelated worktree
+ // churn (activity stamps, unread flags) can't re-issue one read per linked ticket.
+ }, [
+ fetchLinearIssue,
+ inOrcaLinkedLinearRefsSignature,
+ linearConnected,
+ linearMode,
+ linearRefreshNonce,
+ linearTaskSourceContext,
+ refreshLinearIssue,
+ selectedLinearWorkspaceId,
+ taskResumeApplied,
+ taskSource
+ ])
+
useEffect(() => {
if (!taskResumeApplied) {
return
@@ -7935,6 +8095,15 @@ export default function TaskPage(): React.JSX.Element {
[openComposerForLinearItem]
)
+ const handleOpenOrUseLinearItem = useCallback(
+ (issue: LinearIssue): void => {
+ if (openLinearIssueWorkspaceOrStart(issue, () => handleUseLinearItem(issue)) === 'opened') {
+ useAppStore.getState().recordFeatureInteraction('linear-tasks')
+ }
+ },
+ [handleUseLinearItem]
+ )
+
const handleLinearWorkspaceChange = useCallback(
(workspaceId: LinearWorkspaceSelection): void => {
clearSelectedLinearIssue()
@@ -8608,18 +8777,41 @@ export default function TaskPage(): React.JSX.Element {
>
{linearModeOptions.map((mode) => {
const active = linearMode === mode.id
+ const buttonClassName = cn(
+ 'rounded-md border px-2 py-1 text-xs transition',
+ active
+ ? 'border-border/50 bg-foreground/90 text-background'
+ : 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
+ )
+ if (mode.id === 'in-orca') {
+ return (
+
+
+ selectLinearMode(mode.id)}
+ className={buttonClassName}
+ >
+ {mode.label}
+
+
+
+ {translate(
+ 'auto.components.TaskPage.linearModeHasWorktreeTooltip',
+ 'Linear tickets linked to an Orca workspace'
+ )}
+
+
+ )
+ }
return (
selectLinearMode(mode.id)}
- className={cn(
- 'rounded-md border px-2 py-1 text-xs transition',
- active
- ? 'border-border/50 bg-foreground/90 text-background'
- : 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
- )}
+ className={buttonClassName}
>
{mode.label}
@@ -8704,7 +8896,7 @@ export default function TaskPage(): React.JSX.Element {
size="icon"
onClick={() => setLinearRefreshNonce((n) => n + 1)}
disabled={
- linearMode === 'issues'
+ linearMode === 'issues' || linearMode === 'in-orca'
? linearLoading
: linearMode === 'projects'
? linearProjectsLoading || linearProjectDetailLoading
@@ -8716,7 +8908,8 @@ export default function TaskPage(): React.JSX.Element {
)}
className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent"
>
- {linearMode === 'issues' && linearLoading ? (
+ {(linearMode === 'issues' || linearMode === 'in-orca') &&
+ linearLoading ? (
) : linearMode === 'projects' &&
(linearProjectsLoading || linearProjectDetailLoading) ? (
@@ -8736,7 +8929,7 @@ export default function TaskPage(): React.JSX.Element {
- {linearMode === 'issues' ? (
+ {linearMode === 'issues' || linearMode === 'in-orca' ? (
{showLinearAttributeFilters ? (
n + 1)
+ setTaskResumeState({
+ linearQuery: trimmed,
+ linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues'
+ })
+ if (linearMode !== 'in-orca') {
+ setLinearRefreshNonce((n) => n + 1)
+ }
}
}}
- placeholder={translate(
- 'auto.components.TaskPage.eec0c5c079',
- 'Search Linear issues...'
- )}
+ placeholder={
+ linearMode === 'in-orca'
+ ? translate(
+ 'auto.components.TaskPage.linearHasWorktreeSearchPlaceholder',
+ 'Filter issues linked to an Orca workspace...'
+ )
+ : translate(
+ 'auto.components.TaskPage.eec0c5c079',
+ 'Search Linear issues...'
+ )
+ }
className="h-8 rounded-md border-border/50 bg-background pl-8 pr-8 text-xs"
/>
{linearSearchInput ? (
@@ -8792,8 +8997,13 @@ export default function TaskPage(): React.JSX.Element {
onClick={() => {
setLinearSearchInput('')
setAppliedLinearSearch('')
- setTaskResumeState({ linearQuery: '', linearMode: 'issues' })
- setLinearRefreshNonce((n) => n + 1)
+ setTaskResumeState({
+ linearQuery: '',
+ linearMode: linearMode === 'in-orca' ? 'in-orca' : 'issues'
+ })
+ if (linearMode !== 'in-orca') {
+ setLinearRefreshNonce((n) => n + 1)
+ }
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
@@ -10325,7 +10535,12 @@ export default function TaskPage(): React.JSX.Element {
) : null}
{activeLinearIssueContextLabel ??
- translate('auto.components.TaskPage.60f68a2ef4', 'Linear issues')}
+ (linearMode === 'in-orca'
+ ? translate(
+ 'auto.components.TaskPage.linearModeHasWorktree',
+ 'Has Workspace'
+ )
+ : translate('auto.components.TaskPage.60f68a2ef4', 'Linear issues'))}
@@ -10472,7 +10687,9 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('updated') ? (
{translate('auto.components.TaskPage.f362667d55', 'Updated')}
) : null}
-
+
+ {translate('auto.components.TaskPage.linearWorktreesColumn', 'Workspaces')}
+
) : null}
@@ -10527,6 +10744,18 @@ export default function TaskPage(): React.JSX.Element {
{(() => {
+ if (linearMode === 'in-orca') {
+ if (linearSearchActive) {
+ return translate(
+ 'auto.components.TaskPage.2bdefbcac3',
+ 'Try a different search query.'
+ )
+ }
+ return translate(
+ 'auto.components.TaskPage.linearEmptyHasWorktree',
+ 'No Linear tickets are linked to an Orca workspace yet. Start work from a Linear issue to see it here.'
+ )
+ }
const emptyKind = resolveLinearIssueEmptyKind({
hasContextLabel: Boolean(activeLinearIssueContextLabel),
searchActive: linearSearchActive,
@@ -10566,18 +10795,26 @@ export default function TaskPage(): React.JSX.Element {
filteredLinearIssues.length === 0 ? (
- {translate(
- 'auto.components.TaskPage.618107fab3',
- 'No fetched issues match the selected teams'
- )}
+ {linearMode === 'in-orca' && linearSearchActive
+ ? translate('auto.components.TaskPage.903c7af49f', 'No Linear issues found')
+ : translate(
+ 'auto.components.TaskPage.618107fab3',
+ 'No fetched issues match the selected teams'
+ )}
- {translate(
- 'auto.components.TaskPage.592a55611b',
- 'Try selecting more teams or refreshing; team filters apply to the current fetched issue set.'
- )}
+ {linearMode === 'in-orca' && linearSearchActive
+ ? translate(
+ 'auto.components.TaskPage.2bdefbcac3',
+ 'Try a different search query.'
+ )
+ : translate(
+ 'auto.components.TaskPage.592a55611b',
+ 'Try selecting more teams or refreshing; team filters apply to the current fetched issue set.'
+ )}
- {shouldOfferLinearIssueFetchMore({
+ {linearMode !== 'in-orca' &&
+ shouldOfferLinearIssueFetchMore({
emptyKind: 'client-team',
serverHasMore: linearIssuesHasMore
}) ? (
@@ -10632,6 +10869,13 @@ export default function TaskPage(): React.JSX.Element {
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
: issue.team.name
+ const attachedWorkspace = findLinearIssueWorkspaceAttachmentInIndex(
+ linearIssueAttachmentIndex,
+ issue
+ )
+ const attachedWorkspaceLabel = attachedWorkspace
+ ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace)
+ : null
return (
-
-
{
- event.stopPropagation()
- handleUseLinearItem(issue)
- }}
- aria-label={translate(
- 'auto.components.TaskPage.ff90d0abc7',
- 'Start workspace from {{value0}}',
- { value0: issue.identifier }
- )}
- >
-
-
+
+
+
+ {
+ event.stopPropagation()
+ handleOpenOrUseLinearItem(issue)
+ }}
+ aria-label={
+ attachedWorkspace
+ ? translate(
+ 'auto.components.TaskPage.linearOpenAttachedWorkspace',
+ 'Open workspace attached to {{value0}}',
+ { value0: issue.identifier }
+ )
+ : translate(
+ 'auto.components.TaskPage.ff90d0abc7',
+ 'Start workspace from {{value0}}',
+ { value0: issue.identifier }
+ )
+ }
+ >
+ {attachedWorkspace ? (
+
+ ) : (
+
+ )}
+
+
+
+ {attachedWorkspace
+ ? translate('auto.components.TaskPage.606a85c774', 'Open')
+ : translate(
+ 'auto.components.TaskPage.7d08e8be0f',
+ 'Start'
+ )}
+
+
{formatRelativeTime(issue.updatedAt)}
) : null}
+ {attachedWorkspaceLabel ? (
+
+
+ {attachedWorkspaceLabel}
+
+ ) : null}
{effectiveLinearDisplayProperties.has('labels') &&
issue.labels.length > 0 ? (
@@ -10793,6 +11068,13 @@ export default function TaskPage(): React.JSX.Element {
selectedLinearWorkspaceId === 'all' && issue.workspaceName
? `${issue.workspaceName} / ${issue.team.name}`
: issue.team.name
+ const attachedWorkspace = findLinearIssueWorkspaceAttachmentInIndex(
+ linearIssueAttachmentIndex,
+ issue
+ )
+ const attachedWorkspaceLabel = attachedWorkspace
+ ? getLinearIssueWorkspaceAttachmentLabel(attachedWorkspace)
+ : null
return (
) : null}
+ {attachedWorkspaceLabel ? (
+
+
+ {attachedWorkspaceLabel}
+
+ ) : null}
@@ -10935,28 +11223,46 @@ export default function TaskPage(): React.JSX.Element {
) : null}
-
+
{
event.stopPropagation()
- handleUseLinearItem(issue)
+ handleOpenOrUseLinearItem(issue)
}}
- aria-label={translate(
- 'auto.components.TaskPage.ff90d0abc7',
- 'Start workspace from {{value0}}',
- { value0: issue.identifier }
- )}
+ className={attachedWorkspace ? 'shadow-xs' : undefined}
+ aria-label={
+ attachedWorkspace
+ ? translate(
+ 'auto.components.TaskPage.linearOpenAttachedWorkspace',
+ 'Open workspace attached to {{value0}}',
+ { value0: issue.identifier }
+ )
+ : translate(
+ 'auto.components.TaskPage.ff90d0abc7',
+ 'Start workspace from {{value0}}',
+ { value0: issue.identifier }
+ )
+ }
>
-
+ {attachedWorkspace ? (
+
+ ) : (
+
+ )}
- {translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
+ {attachedWorkspace
+ ? (attachedWorkspaceLabel ??
+ translate('auto.components.TaskPage.606a85c774', 'Open'))
+ : translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts
new file mode 100644
index 000000000..28804568f
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-in-orca-issues.test.ts
@@ -0,0 +1,189 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ collectLinkedLinearIssueRefsFromWorktrees,
+ filterLinearIssuesBySearchQuery,
+ filterLinearIssuesForInOrcaWorkspace,
+ linkedLinearIssueRefsSignature,
+ readLinkedLinearIssuesWithLimit
+} from './task-page-linear-in-orca-issues'
+import type { LinearIssue } from '../../../shared/types'
+
+function issue(
+ partial: Partial & Pick
+): LinearIssue {
+ return {
+ title: partial.title ?? partial.identifier,
+ url: partial.url ?? `https://linear.app/acme/issue/${partial.identifier}`,
+ state: partial.state ?? { name: 'Todo', type: 'unstarted', color: '#000' },
+ team: partial.team ?? { id: 'team-1', name: 'Eng', key: 'ENG' },
+ labels: partial.labels ?? [],
+ labelIds: partial.labelIds ?? [],
+ priority: partial.priority ?? 0,
+ updatedAt: partial.updatedAt ?? '2026-01-01T00:00:00.000Z',
+ workspaceId: partial.workspaceId,
+ assignee: partial.assignee,
+ ...partial
+ }
+}
+
+describe('collectLinkedLinearIssueRefsFromWorktrees', () => {
+ it('dedupes linked Linear identifiers case-insensitively', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees([
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: 'ws-a' },
+ { linkedLinearIssue: 'eng-1', linkedLinearIssueWorkspaceId: null },
+ { linkedLinearIssue: 'ENG-2', linkedLinearIssueWorkspaceId: 'ws-b' },
+ { linkedLinearIssue: null, linkedLinearIssueWorkspaceId: null },
+ { linkedLinearIssue: ' ', linkedLinearIssueWorkspaceId: 'ws-a' }
+ ])
+ ).toEqual([
+ { identifier: 'ENG-1', workspaceId: 'ws-a' },
+ { identifier: 'ENG-2', workspaceId: 'ws-b' }
+ ])
+ })
+
+ it('normalizes URL-valued and lower-cased links to bare upper-case identifiers', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees([
+ {
+ linkedLinearIssue: 'https://linear.app/acme/issue/eng-7/fix-it',
+ linkedLinearIssueWorkspaceId: null
+ },
+ { linkedLinearIssue: 'eng-8', linkedLinearIssueWorkspaceId: null }
+ ])
+ ).toEqual([
+ { identifier: 'ENG-7', workspaceId: null, organizationUrlKey: 'acme' },
+ { identifier: 'ENG-8', workspaceId: null }
+ ])
+ })
+
+ it('prefers a concrete workspace id when later worktrees provide one', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees([
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: null },
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: 'ws-a' }
+ ])
+ ).toEqual([{ identifier: 'ENG-1', workspaceId: 'ws-a' }])
+ })
+
+ it('keeps identical identifiers from different Linear workspaces distinct', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees([
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: 'ws-a' },
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: 'ws-b' }
+ ])
+ ).toEqual([
+ { identifier: 'ENG-1', workspaceId: 'ws-a' },
+ { identifier: 'ENG-1', workspaceId: 'ws-b' }
+ ])
+ })
+
+ it('resolves URL organization scope and excludes archived worktrees', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees(
+ [
+ {
+ linkedLinearIssue: 'https://linear.app/acme/issue/eng-1/title',
+ linkedLinearIssueWorkspaceId: null
+ },
+ { linkedLinearIssue: 'ENG-2', isArchived: true }
+ ],
+ {
+ workspaces: [{ id: 'ws-a', organizationUrlKey: 'acme' }]
+ }
+ )
+ ).toEqual([{ identifier: 'ENG-1', workspaceId: 'ws-a', organizationUrlKey: 'acme' }])
+ })
+
+ it('filters by selected workspace when worktrees carry workspace ids', () => {
+ expect(
+ collectLinkedLinearIssueRefsFromWorktrees(
+ [
+ { linkedLinearIssue: 'ENG-1', linkedLinearIssueWorkspaceId: 'ws-a' },
+ { linkedLinearIssue: 'ENG-2', linkedLinearIssueWorkspaceId: 'ws-b' },
+ { linkedLinearIssue: 'ENG-3', linkedLinearIssueWorkspaceId: null }
+ ],
+ { workspaceId: 'ws-a' }
+ )
+ ).toEqual([
+ { identifier: 'ENG-1', workspaceId: 'ws-a' },
+ { identifier: 'ENG-3', workspaceId: null }
+ ])
+ })
+})
+
+describe('filterLinearIssuesForInOrcaWorkspace', () => {
+ it('keeps issues without workspace metadata when a workspace is selected', () => {
+ const issues = [
+ issue({ id: '1', identifier: 'ENG-1', workspaceId: 'ws-a' }),
+ issue({ id: '2', identifier: 'ENG-2', workspaceId: 'ws-b' }),
+ issue({ id: '3', identifier: 'ENG-3' })
+ ]
+ expect(
+ filterLinearIssuesForInOrcaWorkspace(issues, 'ws-a').map((item) => item.identifier)
+ ).toEqual(['ENG-1', 'ENG-3'])
+ })
+})
+
+describe('filterLinearIssuesBySearchQuery', () => {
+ it('matches identifier, title, team, and assignee', () => {
+ const issues = [
+ issue({
+ id: '1',
+ identifier: 'ENG-1',
+ title: 'Fix login',
+ team: { id: 't1', name: 'Platform', key: 'ENG' },
+ assignee: { id: 'u1', displayName: 'Ada' }
+ }),
+ issue({ id: '2', identifier: 'ENG-2', title: 'Other' })
+ ]
+ expect(filterLinearIssuesBySearchQuery(issues, 'login').map((item) => item.id)).toEqual(['1'])
+ expect(filterLinearIssuesBySearchQuery(issues, 'platform').map((item) => item.id)).toEqual([
+ '1'
+ ])
+ expect(filterLinearIssuesBySearchQuery(issues, 'ada').map((item) => item.id)).toEqual(['1'])
+ expect(filterLinearIssuesBySearchQuery(issues, 'eng-2').map((item) => item.id)).toEqual(['2'])
+ })
+})
+
+describe('linkedLinearIssueRefsSignature', () => {
+ it('is stable regardless of input order', () => {
+ expect(
+ linkedLinearIssueRefsSignature([
+ { identifier: 'ENG-2', workspaceId: 'b' },
+ { identifier: 'eng-1', workspaceId: 'a' }
+ ])
+ ).toBe(
+ linkedLinearIssueRefsSignature([
+ { identifier: 'ENG-1', workspaceId: 'a' },
+ { identifier: 'ENG-2', workspaceId: 'b' }
+ ])
+ )
+ })
+})
+
+describe('readLinkedLinearIssuesWithLimit', () => {
+ it('preserves input order while bounding concurrent reads', async () => {
+ let active = 0
+ let maxActive = 0
+ const refs = Array.from({ length: 9 }, (_, index) => ({
+ identifier: `ENG-${index + 1}`,
+ workspaceId: null
+ }))
+ const results = await readLinkedLinearIssuesWithLimit(
+ refs,
+ async (ref) => {
+ active += 1
+ maxActive = Math.max(maxActive, active)
+ await Promise.resolve()
+ active -= 1
+ return issue({ id: ref.identifier, identifier: ref.identifier })
+ },
+ 3
+ )
+
+ expect(maxActive).toBe(3)
+ expect(results.map((item) => item?.identifier)).toEqual(refs.map((ref) => ref.identifier))
+ })
+})
diff --git a/src/renderer/src/components/task-page-linear-in-orca-issues.ts b/src/renderer/src/components/task-page-linear-in-orca-issues.ts
new file mode 100644
index 000000000..3db29f318
--- /dev/null
+++ b/src/renderer/src/components/task-page-linear-in-orca-issues.ts
@@ -0,0 +1,178 @@
+import type { LinearIssue, LinearWorkspace, Worktree } from '../../../shared/types'
+import { parseLinearIssueInput } from '../../../shared/linear-links'
+import {
+ getTaskSourceCacheScope,
+ type TaskSourceContext
+} from '../../../shared/task-source-context'
+import { normalizeLinearIdentifier } from '../lib/linear-issue-workspace-attachment'
+
+export type LinkedLinearIssueRef = {
+ identifier: string
+ workspaceId: string | null
+ organizationUrlKey?: string
+ sourceContext?: TaskSourceContext | null
+}
+
+type LinkedLinearWorktreeFields = Pick &
+ Partial<
+ Pick<
+ Worktree,
+ | 'isArchived'
+ | 'linkedLinearIssueWorkspaceId'
+ | 'linkedLinearIssueOrganizationUrlKey'
+ | 'linkedTaskSourceContext'
+ >
+ >
+
+// Why: Has Workspace is an Orca workspace view, not a Linear API filter.
+export function collectLinkedLinearIssueRefsFromWorktrees(
+ worktrees: readonly LinkedLinearWorktreeFields[],
+ options?: {
+ workspaceId?: string | null
+ workspaces?: readonly Pick[]
+ }
+): LinkedLinearIssueRef[] {
+ const selectedWorkspaceId =
+ options?.workspaceId && options.workspaceId !== 'all' ? options.workspaceId : null
+ const workspaceIdByOrgKey = new Map()
+ for (const workspace of options?.workspaces ?? []) {
+ if (workspace.organizationUrlKey) {
+ workspaceIdByOrgKey.set(workspace.organizationUrlKey.toLowerCase(), workspace.id)
+ }
+ }
+ const byIdentifier = new Map()
+
+ for (const worktree of worktrees) {
+ if (worktree.isArchived) {
+ continue
+ }
+ // Why: links can be stored as a URL or in any casing; the Linear read needs the bare identifier.
+ const identifier = normalizeLinearIdentifier(worktree.linkedLinearIssue)
+ if (!identifier) {
+ continue
+ }
+ const organizationUrlKey =
+ worktree.linkedLinearIssueOrganizationUrlKey?.trim() ||
+ parseLinearIssueInput(worktree.linkedLinearIssue ?? '')?.organizationUrlKey
+ const sourceContext = worktree.linkedTaskSourceContext
+ const sourceWorkspaceId =
+ sourceContext?.providerIdentity?.provider === 'linear'
+ ? sourceContext.providerIdentity.workspaceId
+ : null
+ const workspaceId =
+ worktree.linkedLinearIssueWorkspaceId?.trim() ||
+ sourceWorkspaceId?.trim() ||
+ (organizationUrlKey
+ ? (workspaceIdByOrgKey.get(organizationUrlKey.toLowerCase()) ?? null)
+ : null)
+ if (selectedWorkspaceId && workspaceId && workspaceId !== selectedWorkspaceId) {
+ continue
+ }
+ const ref: LinkedLinearIssueRef = {
+ identifier,
+ workspaceId,
+ ...(organizationUrlKey ? { organizationUrlKey } : {}),
+ ...(sourceContext !== undefined ? { sourceContext } : {})
+ }
+ const sourceScope = sourceContext ? getTaskSourceCacheScope(sourceContext) : ''
+ const refScope = `${workspaceId ?? ''}::${organizationUrlKey?.toLowerCase() ?? ''}::${sourceScope}`
+ const existing = byIdentifier.get(identifier)
+ if (!existing) {
+ byIdentifier.set(identifier, [ref])
+ continue
+ }
+ if (
+ existing.some((candidate) => {
+ const candidateSourceScope = candidate.sourceContext
+ ? getTaskSourceCacheScope(candidate.sourceContext)
+ : ''
+ return (
+ `${candidate.workspaceId ?? ''}::${candidate.organizationUrlKey?.toLowerCase() ?? ''}::${candidateSourceScope}` ===
+ refScope
+ )
+ })
+ ) {
+ continue
+ }
+ const unscopedIndex = existing.findIndex(
+ (candidate) =>
+ !candidate.workspaceId &&
+ !candidate.organizationUrlKey &&
+ (candidate.sourceContext ? getTaskSourceCacheScope(candidate.sourceContext) : '') ===
+ sourceScope
+ )
+ if ((workspaceId || organizationUrlKey) && unscopedIndex >= 0) {
+ existing[unscopedIndex] = ref
+ } else if (!workspaceId && !organizationUrlKey) {
+ const hasSameSourceScope = existing.some(
+ (candidate) =>
+ (candidate.sourceContext ? getTaskSourceCacheScope(candidate.sourceContext) : '') ===
+ sourceScope
+ )
+ if (!hasSameSourceScope) {
+ existing.push(ref)
+ }
+ } else {
+ existing.push(ref)
+ }
+ }
+
+ return [...byIdentifier.values()].flat()
+}
+
+export function filterLinearIssuesForInOrcaWorkspace(
+ issues: readonly LinearIssue[],
+ workspaceId: string | null | undefined
+): LinearIssue[] {
+ if (!workspaceId || workspaceId === 'all') {
+ return [...issues]
+ }
+ return issues.filter((issue) => !issue.workspaceId || issue.workspaceId === workspaceId)
+}
+
+export function filterLinearIssuesBySearchQuery(
+ issues: readonly LinearIssue[],
+ query: string
+): LinearIssue[] {
+ const trimmed = query.trim().toLowerCase()
+ if (!trimmed) {
+ return [...issues]
+ }
+ return issues.filter((issue) => {
+ return (
+ issue.identifier.toLowerCase().includes(trimmed) ||
+ issue.title.toLowerCase().includes(trimmed) ||
+ issue.team.name.toLowerCase().includes(trimmed) ||
+ (issue.assignee?.displayName.toLowerCase().includes(trimmed) ?? false)
+ )
+ })
+}
+
+export function linkedLinearIssueRefsSignature(refs: readonly LinkedLinearIssueRef[]): string {
+ return refs
+ .map((ref) => {
+ const sourceScope = ref.sourceContext ? getTaskSourceCacheScope(ref.sourceContext) : ''
+ return `${ref.identifier.toUpperCase()}::${ref.workspaceId ?? ''}::${ref.organizationUrlKey?.toLowerCase() ?? ''}::${sourceScope}`
+ })
+ .sort()
+ .join('|')
+}
+
+export async function readLinkedLinearIssuesWithLimit(
+ refs: readonly LinkedLinearIssueRef[],
+ read: (ref: LinkedLinearIssueRef) => Promise,
+ concurrency = 6
+): Promise<(LinearIssue | null)[]> {
+ const results = Array.from({ length: refs.length }, (): LinearIssue | null => null)
+ let nextIndex = 0
+ const workerCount = Math.min(refs.length, Math.max(1, Math.floor(concurrency)))
+ await Promise.all(
+ Array.from({ length: workerCount }, async () => {
+ while (nextIndex < refs.length) {
+ const index = nextIndex++
+ results[index] = await read(refs[index])
+ }
+ })
+ )
+ return results
+}
diff --git a/src/renderer/src/components/task-page-localized-options.tsx b/src/renderer/src/components/task-page-localized-options.tsx
index 3d0215785..c4498969d 100644
--- a/src/renderer/src/components/task-page-localized-options.tsx
+++ b/src/renderer/src/components/task-page-localized-options.tsx
@@ -31,7 +31,7 @@ export type JiraPreset = { id: JiraPresetId; label: string }
export type GitHubModeButton = { id: GitHubTaskKind | 'project'; label: string }
export type LinearViewMode = 'list' | 'board'
-export type LinearMode = 'issues' | 'projects' | 'views'
+export type LinearMode = 'issues' | 'projects' | 'views' | 'in-orca'
export type LinearGroupBy = 'none' | 'status' | 'assignee' | 'priority' | 'team'
export type LinearOrderBy = 'priority' | 'updated' | 'identifier'
export type LinearDisplayProperty =
@@ -144,7 +144,11 @@ export const getLinearModeOptions = createLocalizedCatalog(
(): { id: LinearMode; label: string }[] => [
{ id: 'issues', label: translate('auto.components.TaskPage.dfc0c79bd8', 'Issues') },
{ id: 'projects', label: translate('auto.components.TaskPage.727069bee5', 'Projects') },
- { id: 'views', label: translate('auto.components.TaskPage.e78ec261ed', 'Views') }
+ { id: 'views', label: translate('auto.components.TaskPage.e78ec261ed', 'Views') },
+ {
+ id: 'in-orca',
+ label: translate('auto.components.TaskPage.linearModeHasWorktree', 'Has Workspace')
+ }
]
)
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 9745a1923..53c5af930 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -673,6 +673,13 @@
"triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.",
"triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup."
}
+ },
+ "issue": {
+ "workspace": {
+ "open": {
+ "4f2c1d8a3b": "Unable to open the workspace attached to this issue."
+ }
+ }
}
},
"codex": {
@@ -1256,7 +1263,13 @@
"af6e02c44a": "page",
"76ffd3c937": "Search for a project to add.",
"c11b4e3cc2": "No projects found.",
- "519c3587f3": "Add to project"
+ "519c3587f3": "Add to project",
+ "openAttachedWorkspace": "Open workspace attached to issue",
+ "openWorkspace": "Open workspace",
+ "moreWorkspaceActions": "More issue workspace actions",
+ "startNewWorkspace": "Start new workspace",
+ "workspaceSection": "Workspace",
+ "noWorkspaceYet": "None yet"
},
"LinearItemDrawer": {
"04008e6c46": "Start workspace from issue",
@@ -1288,7 +1301,11 @@
"39883467f4": "Linear issue",
"fda549766e": "{{value0}} to comment",
"d71cd3003e": "+ Assignee",
- "commentTooLarge": "Comment is too large to submit safely."
+ "commentTooLarge": "Comment is too large to submit safely.",
+ "openAttachedWorkspace": "Open workspace attached to issue",
+ "openWorkspace": "Open workspace",
+ "moreWorkspaceActions": "More issue workspace actions",
+ "startNewWorkspace": "Start new workspace"
},
"NewWorkspaceComposerCard": {
"reuseExistingBranch": "Reuse branch",
@@ -1907,7 +1924,15 @@
"jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.",
"loadPageUnreachable": "Page {{value0}} is beyond what GitHub search can return.",
"loadPageFailed": "Page {{value0}} could not be loaded from GitHub.",
- "loadPageNoMoreResults": "No more results on page {{value0}}."
+ "loadPageNoMoreResults": "No more results on page {{value0}}.",
+ "linearHasWorktreeLoadFailed": "Unable to load Linear issues linked to an Orca workspace.",
+ "linearHasWorktreePartialLoadFailed": "Some Linear issues linked to an Orca workspace could not be loaded. Refresh to try again.",
+ "linearHasWorktreeSearchPlaceholder": "Filter issues linked to an Orca workspace...",
+ "linearModeHasWorktree": "Has Workspace",
+ "linearModeHasWorktreeTooltip": "Linear tickets linked to an Orca workspace",
+ "linearEmptyHasWorktree": "No Linear tickets are linked to an Orca workspace yet. Start work from a Linear issue to see it here.",
+ "linearOpenAttachedWorkspace": "Open workspace attached to {{value0}}",
+ "linearWorktreesColumn": "Workspaces"
},
"Terminal": {
"73768427cf": "Close",
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index dbe94a85a..095de652a 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -629,6 +629,13 @@
"triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.",
"triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup."
}
+ },
+ "issue": {
+ "workspace": {
+ "open": {
+ "4f2c1d8a3b": "No se pudo abrir el espacio de trabajo vinculado a este issue."
+ }
+ }
}
},
"codex": {
@@ -1208,7 +1215,13 @@
"af6e02c44a": "página",
"76ffd3c937": "Busca un proyecto para agregar.",
"c11b4e3cc2": "No se encontraron proyectos.",
- "519c3587f3": "Agregar al proyecto"
+ "519c3587f3": "Agregar al proyecto",
+ "openAttachedWorkspace": "Abrir el espacio de trabajo vinculado al issue",
+ "openWorkspace": "Abrir espacio de trabajo",
+ "moreWorkspaceActions": "Más acciones del espacio de trabajo del issue",
+ "startNewWorkspace": "Iniciar un espacio de trabajo nuevo",
+ "workspaceSection": "Espacio de trabajo",
+ "noWorkspaceYet": "Ninguno todavía"
},
"LinearItemDrawer": {
"04008e6c46": "Iniciar espacio de trabajo desde el issue",
@@ -1240,7 +1253,11 @@
"39883467f4": "Issue de Linear",
"fda549766e": "{{value0}} para comentar",
"d71cd3003e": "+ Asignado",
- "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura."
+ "commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.",
+ "openAttachedWorkspace": "Abrir el espacio de trabajo vinculado al issue",
+ "openWorkspace": "Abrir espacio de trabajo",
+ "moreWorkspaceActions": "Más acciones del espacio de trabajo del issue",
+ "startNewWorkspace": "Iniciar un espacio de trabajo nuevo"
},
"NewWorkspaceComposerCard": {
"cbb47ee0dc": "Solo disponible para proyectos Git locales.",
@@ -1849,6 +1866,14 @@
"linearEmptyAttributeFilter": "Ningún issue coincide con los filtros seleccionados. Limpia un filtro o prueba con otros criterios.",
"linearEmptyUnfilteredScope": "No hay issues en el alcance de este espacio de trabajo. Intenta buscar o ajustar los equipos.",
"linearFetchMore": "Cargar más",
+ "linearHasWorktreeLoadFailed": "No se pudieron cargar los issues de Linear vinculados a un espacio de trabajo de Orca.",
+ "linearHasWorktreePartialLoadFailed": "No se pudieron cargar algunos issues de Linear vinculados a un espacio de trabajo de Orca. Actualiza para volver a intentarlo.",
+ "linearHasWorktreeSearchPlaceholder": "Filtrar issues vinculados a un espacio de trabajo de Orca...",
+ "linearModeHasWorktree": "Con espacio de trabajo",
+ "linearModeHasWorktreeTooltip": "Tickets de Linear vinculados a un espacio de trabajo de Orca",
+ "linearEmptyHasWorktree": "Aún no hay tickets de Linear vinculados a un espacio de trabajo de Orca. Inicia el trabajo desde un issue de Linear para verlo aquí.",
+ "linearOpenAttachedWorkspace": "Abrir el espacio de trabajo vinculado a {{value0}}",
+ "linearWorktreesColumn": "Espacios de trabajo",
"jiraSortAscending": "ascendente",
"jiraSortDescending": "descendente",
"jiraSortBy": "Ordenar por",
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 830e17809..4ed7237ae 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -629,6 +629,13 @@
"triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.",
"triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup."
}
+ },
+ "issue": {
+ "workspace": {
+ "open": {
+ "4f2c1d8a3b": "このイシューにリンクされたワークスペースを開けませんでした。"
+ }
+ }
}
},
"codex": {
@@ -1208,7 +1215,13 @@
"af6e02c44a": "ページ",
"76ffd3c937": "追加するプロジェクトを検索します。",
"c11b4e3cc2": "プロジェクトが見つかりませんでした。",
- "519c3587f3": "プロジェクトに追加"
+ "519c3587f3": "プロジェクトに追加",
+ "openAttachedWorkspace": "イシューにリンクされたワークスペースを開く",
+ "openWorkspace": "ワークスペースを開く",
+ "moreWorkspaceActions": "イシューのワークスペース操作をさらに表示",
+ "startNewWorkspace": "新しいワークスペースを開始",
+ "workspaceSection": "ワークスペース",
+ "noWorkspaceYet": "まだありません"
},
"LinearItemDrawer": {
"04008e6c46": "Issue からワークスペースを開始",
@@ -1240,7 +1253,11 @@
"39883467f4": "Linear Issue",
"fda549766e": "コメントするには{{value0}}",
"d71cd3003e": "+ 担当者",
- "commentTooLarge": "コメントが大きすぎるため安全に送信できません。"
+ "commentTooLarge": "コメントが大きすぎるため安全に送信できません。",
+ "openAttachedWorkspace": "イシューにリンクされたワークスペースを開く",
+ "openWorkspace": "ワークスペースを開く",
+ "moreWorkspaceActions": "イシューのワークスペース操作をさらに表示",
+ "startNewWorkspace": "新しいワークスペースを開始"
},
"NewWorkspaceComposerCard": {
"cbb47ee0dc": "ローカル Git プロジェクトでのみ使用できます。",
@@ -1849,6 +1866,14 @@
"linearEmptyAttributeFilter": "選択したフィルターに一致するイシューがありません。フィルターを解除するか、異なる条件を試してください。",
"linearEmptyUnfilteredScope": "このワークスペーススコープにイシューがありません。検索するか、チームを調整してください。",
"linearFetchMore": "さらに読み込む",
+ "linearHasWorktreeLoadFailed": "Orca ワークスペースにリンクされた Linear イシューを読み込めませんでした。",
+ "linearHasWorktreePartialLoadFailed": "Orca ワークスペースにリンクされた一部の Linear イシューを読み込めませんでした。更新してもう一度お試しください。",
+ "linearHasWorktreeSearchPlaceholder": "Orca ワークスペースにリンクされたイシューを絞り込み...",
+ "linearModeHasWorktree": "ワークスペースあり",
+ "linearModeHasWorktreeTooltip": "Orca ワークスペースにリンクされた Linear チケット",
+ "linearEmptyHasWorktree": "Orca ワークスペースにリンクされた Linear チケットはまだありません。Linear のイシューから作業を開始すると、ここに表示されます。",
+ "linearOpenAttachedWorkspace": "{{value0}} にリンクされたワークスペースを開く",
+ "linearWorktreesColumn": "ワークスペース",
"jiraSortAscending": "昇順",
"jiraSortDescending": "降順",
"jiraSortBy": "並べ替え",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 4d1ed1e28..bb5bbc4b4 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -629,6 +629,13 @@
"triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.",
"triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup."
}
+ },
+ "issue": {
+ "workspace": {
+ "open": {
+ "4f2c1d8a3b": "이 이슈에 연결된 워크스페이스를 열 수 없습니다."
+ }
+ }
}
},
"codex": {
@@ -1208,7 +1215,13 @@
"af6e02c44a": "페이지",
"76ffd3c937": "추가할 프로젝트를 검색하세요.",
"c11b4e3cc2": "프로젝트를 찾을 수 없습니다.",
- "519c3587f3": "프로젝트에 추가"
+ "519c3587f3": "프로젝트에 추가",
+ "openAttachedWorkspace": "이슈에 연결된 워크스페이스 열기",
+ "openWorkspace": "워크스페이스 열기",
+ "moreWorkspaceActions": "이슈 워크스페이스 작업 더 보기",
+ "startNewWorkspace": "새 워크스페이스 시작",
+ "workspaceSection": "워크스페이스",
+ "noWorkspaceYet": "아직 없음"
},
"LinearItemDrawer": {
"04008e6c46": "이슈에서 워크스페이스 시작",
@@ -1240,7 +1253,11 @@
"39883467f4": "Linear 이슈",
"fda549766e": "댓글을 달려면 {{value0}}",
"d71cd3003e": "+ 담당자",
- "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다."
+ "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다.",
+ "openAttachedWorkspace": "이슈에 연결된 워크스페이스 열기",
+ "openWorkspace": "워크스페이스 열기",
+ "moreWorkspaceActions": "이슈 워크스페이스 작업 더 보기",
+ "startNewWorkspace": "새 워크스페이스 시작"
},
"NewWorkspaceComposerCard": {
"cbb47ee0dc": "로컬 Git 프로젝트에만 사용할 수 있습니다.",
@@ -1849,6 +1866,14 @@
"linearEmptyAttributeFilter": "선택한 필터와 일치하는 이슈가 없습니다. 필터를 지우거나 다른 조건을 시도하세요.",
"linearEmptyUnfilteredScope": "이 워크트리 범위에 이슈가 없습니다. 검색하거나 팀을 조정해 보세요.",
"linearFetchMore": "더 불러오기",
+ "linearHasWorktreeLoadFailed": "Orca 워크스페이스에 연결된 Linear 이슈를 불러올 수 없습니다.",
+ "linearHasWorktreePartialLoadFailed": "Orca 워크스페이스에 연결된 일부 Linear 이슈를 불러올 수 없습니다. 새로 고쳐 다시 시도하세요.",
+ "linearHasWorktreeSearchPlaceholder": "Orca 워크스페이스에 연결된 이슈 필터...",
+ "linearModeHasWorktree": "워크스페이스 있음",
+ "linearModeHasWorktreeTooltip": "Orca 워크스페이스에 연결된 Linear 티켓",
+ "linearEmptyHasWorktree": "아직 Orca 워크스페이스에 연결된 Linear 티켓이 없습니다. Linear 이슈에서 작업을 시작하면 여기에 표시됩니다.",
+ "linearOpenAttachedWorkspace": "{{value0}}에 연결된 워크스페이스 열기",
+ "linearWorktreesColumn": "워크스페이스",
"jiraSortAscending": "오름차순",
"jiraSortDescending": "내림차순",
"jiraSortBy": "정렬 기준",
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index a76a2b01b..e7529643e 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -629,6 +629,13 @@
"triageFollowupsSummary": "设置负责人、优先级或预估工时,并创建带父级关联的后续议题。",
"triageFollowupsPrompt": "使用 {{value0}} 对关联的 Linear 议题进行分类处理 — 设置优先级和预估工时 — 并为延后清理创建带父级关联的后续议题。"
}
+ },
+ "issue": {
+ "workspace": {
+ "open": {
+ "4f2c1d8a3b": "无法打开关联到此议题的工作区。"
+ }
+ }
}
},
"codex": {
@@ -1208,7 +1215,13 @@
"af6e02c44a": "页面",
"76ffd3c937": "搜索要添加的项目。",
"c11b4e3cc2": "没有找到项目。",
- "519c3587f3": "添加到项目"
+ "519c3587f3": "添加到项目",
+ "openAttachedWorkspace": "打开关联到议题的工作区",
+ "openWorkspace": "打开工作区",
+ "moreWorkspaceActions": "更多议题工作区操作",
+ "startNewWorkspace": "启动新工作区",
+ "workspaceSection": "工作区",
+ "noWorkspaceYet": "暂无"
},
"LinearItemDrawer": {
"04008e6c46": "从议题开始工作区",
@@ -1240,7 +1253,11 @@
"39883467f4": "Linear 议题",
"fda549766e": "{{value0}} 发表评论",
"d71cd3003e": "+ 负责人",
- "commentTooLarge": "评论过长,无法安全提交。"
+ "commentTooLarge": "评论过长,无法安全提交。",
+ "openAttachedWorkspace": "打开关联到议题的工作区",
+ "openWorkspace": "打开工作区",
+ "moreWorkspaceActions": "更多议题工作区操作",
+ "startNewWorkspace": "启动新工作区"
},
"NewWorkspaceComposerCard": {
"cbb47ee0dc": "仅适用于本地 Git 项目。",
@@ -1849,6 +1866,14 @@
"linearEmptyAttributeFilter": "没有匹配所选筛选条件的问题。请清除筛选条件或尝试其他条件。",
"linearEmptyUnfilteredScope": "此工作区范围内没有问题。请尝试搜索或调整团队。",
"linearFetchMore": "加载更多",
+ "linearHasWorktreeLoadFailed": "无法加载关联到 Orca 工作区的 Linear 议题。",
+ "linearHasWorktreePartialLoadFailed": "部分关联到 Orca 工作区的 Linear 议题无法加载。请刷新后重试。",
+ "linearHasWorktreeSearchPlaceholder": "筛选关联到 Orca 工作区的议题...",
+ "linearModeHasWorktree": "有工作区",
+ "linearModeHasWorktreeTooltip": "已关联 Orca 工作区的 Linear 工单",
+ "linearEmptyHasWorktree": "还没有关联到 Orca 工作区的 Linear 工单。从 Linear 议题开始工作后会显示在这里。",
+ "linearOpenAttachedWorkspace": "打开关联到 {{value0}} 的工作区",
+ "linearWorktreesColumn": "工作区",
"jiraSortAscending": "升序",
"jiraSortDescending": "降序",
"jiraSortBy": "排序方式",
diff --git a/src/renderer/src/lib/github-work-item-workspace-attachment.ts b/src/renderer/src/lib/github-work-item-workspace-attachment.ts
index a3ac4e3d9..7a31c32a7 100644
--- a/src/renderer/src/lib/github-work-item-workspace-attachment.ts
+++ b/src/renderer/src/lib/github-work-item-workspace-attachment.ts
@@ -1,5 +1,5 @@
import type { GitHubWorkItem, Worktree } from '../../../shared/types'
-import { basename } from './path'
+import { getWorktreeAttachmentLabel } from './worktree-attachment-label'
type GitHubWorkItemType = GitHubWorkItem['type']
@@ -41,32 +41,9 @@ export function findGithubIssueWorkspaceAttachment(
}
export function getGithubWorkItemWorkspaceAttachmentLabel(worktree: Worktree): string {
- const displayName = worktree.displayName.trim()
- if (displayName) {
- return displayName
- }
-
- const branch = getBranchLabel(worktree.branch)
- if (branch) {
- return branch
- }
-
- return basename(worktree.path) || worktree.path
+ return getWorktreeAttachmentLabel(worktree)
}
export function getGithubPrWorkspaceAttachmentLabel(worktree: Worktree): string {
- return getGithubWorkItemWorkspaceAttachmentLabel(worktree)
-}
-
-function getBranchLabel(branch: string | null | undefined): string | null {
- const trimmed = branch?.trim()
- if (!trimmed) {
- return null
- }
-
- if (trimmed.startsWith('refs/heads/')) {
- return trimmed.slice('refs/heads/'.length)
- }
-
- return trimmed
+ return getWorktreeAttachmentLabel(worktree)
}
diff --git a/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts b/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts
new file mode 100644
index 000000000..944557312
--- /dev/null
+++ b/src/renderer/src/lib/linear-issue-workspace-attachment.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildLinearIssueWorkspaceAttachmentIndex,
+ findLinearIssueWorkspaceAttachment,
+ findLinearIssueWorkspaceAttachmentInIndex,
+ getLinearIssueWorkspaceAttachmentLabel
+} from './linear-issue-workspace-attachment'
+import type { Worktree } from '../../../shared/types'
+
+function worktree(overrides: Partial = {}): Worktree {
+ return {
+ id: overrides.id ?? 'wt-1',
+ repoId: overrides.repoId ?? 'repo-1',
+ path: overrides.path ?? '/tmp/repo-1/wt-1',
+ head: 'abc123',
+ branch: overrides.branch ?? 'refs/heads/feature/linear-attachment',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: overrides.displayName ?? 'Linear workspace',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: overrides.linkedLinearIssue ?? null,
+ linkedLinearIssueWorkspaceId: overrides.linkedLinearIssueWorkspaceId,
+ linkedLinearIssueOrganizationUrlKey: overrides.linkedLinearIssueOrganizationUrlKey,
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ ...overrides
+ }
+}
+
+describe('Linear issue workspace attachment', () => {
+ it('finds the first non-archived workspace linked to the issue identifier', () => {
+ const first = worktree({ id: 'first', linkedLinearIssue: 'STA-2716' })
+ const second = worktree({ id: 'second', linkedLinearIssue: 'STA-2716' })
+
+ expect(findLinearIssueWorkspaceAttachment([first, second], { identifier: 'STA-2716' })).toBe(
+ first
+ )
+ })
+
+ it('prefers the most recently active workspace when multiple exact links exist', () => {
+ const older = worktree({
+ id: 'older',
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: 'ws-a',
+ lastActivityAt: 10
+ })
+ const newer = worktree({
+ id: 'newer',
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: 'ws-a',
+ lastActivityAt: 20
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([older, newer], {
+ identifier: 'STA-2716',
+ workspaceId: 'ws-a'
+ })
+ ).toBe(newer)
+ })
+
+ it('matches identifiers case-insensitively and from Linear URLs', () => {
+ const attached = worktree({
+ linkedLinearIssue: 'https://linear.app/stably/issue/sta-2716/title'
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([attached], {
+ identifier: 'sta-2716',
+ url: 'https://linear.app/stably/issue/STA-2716/title'
+ })
+ ).toBe(attached)
+ })
+
+ it('does not match archived workspaces', () => {
+ const archived = worktree({ linkedLinearIssue: 'STA-2716', isArchived: true })
+
+ expect(findLinearIssueWorkspaceAttachment([archived], { identifier: 'STA-2716' })).toBeNull()
+ })
+
+ it('does not match a different Linear identifier', () => {
+ const other = worktree({ linkedLinearIssue: 'STA-1' })
+
+ expect(findLinearIssueWorkspaceAttachment([other], { identifier: 'STA-2716' })).toBeNull()
+ })
+
+ it('refuses cross-workspace matches when both sides declare a workspace id', () => {
+ const otherWorkspace = worktree({
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: 'ws-a'
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([otherWorkspace], {
+ identifier: 'STA-2716',
+ workspaceId: 'ws-b'
+ })
+ ).toBeNull()
+ })
+
+ it('matches when only one side has a workspace id', () => {
+ const unscoped = worktree({
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: null
+ })
+ const scoped = worktree({
+ id: 'scoped',
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: 'ws-a'
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([unscoped], {
+ identifier: 'STA-2716',
+ workspaceId: 'ws-a'
+ })
+ ).toBe(unscoped)
+ expect(
+ findLinearIssueWorkspaceAttachment([scoped], {
+ identifier: 'STA-2716'
+ })
+ ).toBe(scoped)
+ })
+
+ it('prefers an exact workspace match over an earlier unscoped legacy link', () => {
+ const legacy = worktree({ linkedLinearIssue: 'STA-2716', linkedLinearIssueWorkspaceId: null })
+ const exact = worktree({
+ id: 'exact',
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueWorkspaceId: 'ws-a'
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([legacy, exact], {
+ identifier: 'STA-2716',
+ workspaceId: 'ws-a'
+ })
+ ).toBe(exact)
+ })
+
+ it('refuses cross-org matches when both sides declare an organization key', () => {
+ const otherOrg = worktree({
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ })
+
+ expect(
+ findLinearIssueWorkspaceAttachment([otherOrg], {
+ identifier: 'STA-2716',
+ url: 'https://linear.app/stably/issue/STA-2716/title'
+ })
+ ).toBeNull()
+ })
+
+ it('resolves the same worktree through the row index as through a linear scan', () => {
+ const worktrees = [
+ worktree({ id: 'archived', linkedLinearIssue: 'STA-2716', isArchived: true }),
+ worktree({
+ id: 'other-org',
+ linkedLinearIssue: 'STA-2716',
+ linkedLinearIssueOrganizationUrlKey: 'acme'
+ }),
+ worktree({ id: 'match', linkedLinearIssue: 'sta-2716' }),
+ worktree({ id: 'unrelated', linkedLinearIssue: 'STA-1' })
+ ]
+ const index = buildLinearIssueWorkspaceAttachmentIndex(worktrees)
+ const issue = {
+ identifier: 'STA-2716',
+ url: 'https://linear.app/stably/issue/STA-2716/title'
+ }
+
+ expect(findLinearIssueWorkspaceAttachmentInIndex(index, issue)).toBe(
+ findLinearIssueWorkspaceAttachment(worktrees, issue)
+ )
+ expect(findLinearIssueWorkspaceAttachmentInIndex(index, issue)?.id).toBe('match')
+ expect(findLinearIssueWorkspaceAttachmentInIndex(index, { identifier: 'STA-9999' })).toBeNull()
+ })
+
+ it('labels attachments without exposing a full path when display or branch is available', () => {
+ expect(
+ getLinearIssueWorkspaceAttachmentLabel(worktree({ displayName: ' Named Linear ' }))
+ ).toBe('Named Linear')
+ expect(
+ getLinearIssueWorkspaceAttachmentLabel(
+ worktree({ displayName: '', branch: 'refs/heads/fix-ci' })
+ )
+ ).toBe('fix-ci')
+ expect(
+ getLinearIssueWorkspaceAttachmentLabel(
+ worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' })
+ )
+ ).toBe('workspace-tail')
+ })
+})
diff --git a/src/renderer/src/lib/linear-issue-workspace-attachment.ts b/src/renderer/src/lib/linear-issue-workspace-attachment.ts
new file mode 100644
index 000000000..afea291b2
--- /dev/null
+++ b/src/renderer/src/lib/linear-issue-workspace-attachment.ts
@@ -0,0 +1,130 @@
+import type { LinearIssue, Worktree } from '../../../shared/types'
+import {
+ getLinearOrganizationUrlKeyFromIssueUrl,
+ parseLinearIssueInput
+} from '../../../shared/linear-links'
+import { getWorktreeAttachmentLabel } from './worktree-attachment-label'
+
+export type LinearIssueAttachmentRef = Pick &
+ Partial>
+
+/** Normalized identifier -> linking worktrees, in worktree order. */
+export type LinearIssueWorkspaceAttachmentIndex = ReadonlyMap
+
+export function normalizeLinearIdentifier(value: string | null | undefined): string | null {
+ const trimmed = value?.trim()
+ if (!trimmed) {
+ return null
+ }
+ const parsed = parseLinearIssueInput(trimmed)
+ return (parsed?.identifier ?? trimmed).toUpperCase()
+}
+
+function scopeMatchScore(args: {
+ issueWorkspaceId?: string | null
+ worktreeWorkspaceId?: string | null
+ issueOrganizationUrlKey?: string | null
+ worktreeOrganizationUrlKey?: string | null
+}): number | null {
+ const issueWorkspaceId = args.issueWorkspaceId?.trim() || null
+ const worktreeWorkspaceId = args.worktreeWorkspaceId?.trim() || null
+ // Why: when both sides declare a workspace, refuse cross-workspace identifier collisions.
+ if (issueWorkspaceId && worktreeWorkspaceId && issueWorkspaceId !== worktreeWorkspaceId) {
+ return null
+ }
+
+ const issueOrgKey = args.issueOrganizationUrlKey?.trim().toLowerCase() || null
+ const worktreeOrgKey = args.worktreeOrganizationUrlKey?.trim().toLowerCase() || null
+ if (issueOrgKey && worktreeOrgKey && issueOrgKey !== worktreeOrgKey) {
+ return null
+ }
+
+ return (
+ Number(Boolean(issueWorkspaceId && worktreeWorkspaceId)) +
+ Number(Boolean(issueOrgKey && worktreeOrgKey))
+ )
+}
+
+function findScopedAttachment(
+ candidates: readonly Worktree[],
+ issue: LinearIssueAttachmentRef
+): Worktree | null {
+ const issueOrganizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
+ let best: Worktree | null = null
+ let bestScore = -1
+ for (const worktree of candidates) {
+ const score = scopeMatchScore({
+ issueWorkspaceId: issue.workspaceId,
+ worktreeWorkspaceId: worktree.linkedLinearIssueWorkspaceId,
+ issueOrganizationUrlKey,
+ worktreeOrganizationUrlKey: worktree.linkedLinearIssueOrganizationUrlKey
+ })
+ if (
+ score != null &&
+ (score > bestScore ||
+ (score === bestScore && best && worktree.lastActivityAt > best.lastActivityAt))
+ ) {
+ best = worktree
+ bestScore = score
+ }
+ }
+ return best
+}
+
+export function findLinearIssueWorkspaceAttachment(
+ worktrees: readonly Worktree[],
+ issue: LinearIssueAttachmentRef
+): Worktree | null {
+ const identifier = normalizeLinearIdentifier(issue.identifier)
+ if (!identifier) {
+ return null
+ }
+
+ return findScopedAttachment(
+ worktrees.filter(
+ (worktree) =>
+ !worktree.isArchived && normalizeLinearIdentifier(worktree.linkedLinearIssue) === identifier
+ ),
+ issue
+ )
+}
+
+/** Why: issue rows would otherwise rescan (and re-parse) every worktree per row; one
+ * pass per worktree list turns each row lookup into a map hit. */
+export function buildLinearIssueWorkspaceAttachmentIndex(
+ worktrees: readonly Worktree[]
+): LinearIssueWorkspaceAttachmentIndex {
+ const index = new Map()
+ for (const worktree of worktrees) {
+ if (worktree.isArchived) {
+ continue
+ }
+ const identifier = normalizeLinearIdentifier(worktree.linkedLinearIssue)
+ if (!identifier) {
+ continue
+ }
+ const bucket = index.get(identifier)
+ if (bucket) {
+ bucket.push(worktree)
+ } else {
+ index.set(identifier, [worktree])
+ }
+ }
+ return index
+}
+
+export function findLinearIssueWorkspaceAttachmentInIndex(
+ index: LinearIssueWorkspaceAttachmentIndex,
+ issue: LinearIssueAttachmentRef
+): Worktree | null {
+ const identifier = normalizeLinearIdentifier(issue.identifier)
+ if (!identifier) {
+ return null
+ }
+ const candidates = index.get(identifier)
+ return candidates ? findScopedAttachment(candidates, issue) : null
+}
+
+export function getLinearIssueWorkspaceAttachmentLabel(worktree: Worktree): string {
+ return getWorktreeAttachmentLabel(worktree)
+}
diff --git a/src/renderer/src/lib/linear-issue-workspace-open.test.ts b/src/renderer/src/lib/linear-issue-workspace-open.test.ts
new file mode 100644
index 000000000..0d6dd0211
--- /dev/null
+++ b/src/renderer/src/lib/linear-issue-workspace-open.test.ts
@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { FolderWorkspace, Worktree } from '../../../shared/types'
+
+const mocks = vi.hoisted(() => ({
+ activateAndRevealFolderWorkspace: vi.fn(),
+ activateAndRevealWorktree: vi.fn(),
+ folderWorkspaces: [] as FolderWorkspace[],
+ worktrees: [] as Worktree[]
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => ({
+ allWorktrees: () => mocks.worktrees,
+ folderWorkspaces: mocks.folderWorkspaces
+ })
+ }
+}))
+vi.mock('@/lib/worktree-activation', () => ({
+ activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace,
+ activateAndRevealWorktree: mocks.activateAndRevealWorktree
+}))
+vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
+
+import { openLinearIssueWorkspaceOrStart } from './linear-issue-workspace-open'
+
+function worktree(overrides: Partial = {}): Worktree {
+ return {
+ id: 'worktree-1',
+ repoId: 'repo-1',
+ path: '/repo/worktree-1',
+ head: 'abc',
+ branch: 'refs/heads/feature',
+ isBare: false,
+ isMainWorktree: false,
+ displayName: 'Worktree',
+ comment: '',
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: 'ENG-1',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ ...overrides
+ }
+}
+
+function folderWorkspace(): FolderWorkspace {
+ return {
+ id: 'folder-1',
+ projectGroupId: 'group-1',
+ name: 'Folder workspace',
+ folderPath: '/repo/folder-1',
+ linkedTask: {
+ provider: 'linear',
+ type: 'issue',
+ number: 1,
+ title: 'Issue',
+ url: 'https://linear.app/acme/issue/ENG-1/title',
+ linearIdentifier: 'ENG-1'
+ },
+ comment: '',
+ isArchived: false,
+ isUnread: false,
+ isPinned: false,
+ sortOrder: 0,
+ lastActivityAt: 0,
+ createdAt: 0,
+ updatedAt: 0
+ }
+}
+
+describe('openLinearIssueWorkspaceOrStart', () => {
+ beforeEach(() => {
+ mocks.worktrees = []
+ mocks.folderWorkspaces = []
+ mocks.activateAndRevealFolderWorkspace.mockReset().mockReturnValue({ primaryTabId: null })
+ mocks.activateAndRevealWorktree.mockReset().mockReturnValue({ primaryTabId: null })
+ })
+
+ it('qualifies remote worktree activation with its execution host', () => {
+ mocks.worktrees = [worktree({ hostId: 'ssh:builder' })]
+
+ expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, vi.fn())).toBe('opened')
+ expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
+ executionHostId: 'ssh:builder'
+ })
+ })
+
+ it('opens a linked folder workspace instead of starting a duplicate', () => {
+ mocks.folderWorkspaces = [folderWorkspace()]
+ const startWorkspace = vi.fn()
+
+ expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, startWorkspace)).toBe('opened')
+ expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-1', {
+ executionHostId: 'local'
+ })
+ expect(startWorkspace).not.toHaveBeenCalled()
+ })
+
+ it('starts a workspace when the issue has none attached', () => {
+ const startWorkspace = vi.fn()
+
+ expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, startWorkspace)).toBe('started')
+ expect(startWorkspace).toHaveBeenCalledTimes(1)
+ expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
+ })
+
+ it('reports failure when activation is refused', () => {
+ mocks.worktrees = [worktree()]
+ mocks.activateAndRevealWorktree.mockReturnValue(false)
+ const startWorkspace = vi.fn()
+
+ expect(openLinearIssueWorkspaceOrStart({ identifier: 'ENG-1' }, startWorkspace)).toBe('failed')
+ expect(startWorkspace).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/lib/linear-issue-workspace-open.ts b/src/renderer/src/lib/linear-issue-workspace-open.ts
new file mode 100644
index 000000000..4b838176f
--- /dev/null
+++ b/src/renderer/src/lib/linear-issue-workspace-open.ts
@@ -0,0 +1,56 @@
+import { toast } from 'sonner'
+
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+import {
+ activateAndRevealFolderWorkspace,
+ activateAndRevealWorktree
+} from '@/lib/worktree-activation'
+import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
+import { parseWorkspaceKey } from '../../../shared/workspace-scope'
+import {
+ findLinearIssueWorkspaceAttachment,
+ type LinearIssueAttachmentRef
+} from '@/lib/linear-issue-workspace-attachment'
+
+/**
+ * Opens the workspace already attached to a Linear issue, or starts a new one.
+ * Re-reads worktrees from the store so a workspace created since the last render
+ * is still honoured instead of starting a duplicate.
+ */
+export function openLinearIssueWorkspaceOrStart(
+ issue: LinearIssueAttachmentRef,
+ startWorkspace: () => void
+): 'opened' | 'started' | 'failed' {
+ const state = useAppStore.getState()
+ const attached = findLinearIssueWorkspaceAttachment(
+ [...state.allWorktrees(), ...state.folderWorkspaces.map(folderWorkspaceToWorktree)],
+ issue
+ )
+ if (!attached) {
+ startWorkspace()
+ return 'started'
+ }
+
+ const workspaceScope = parseWorkspaceKey(attached.id)
+ const activation =
+ workspaceScope?.type === 'folder'
+ ? activateAndRevealFolderWorkspace(
+ workspaceScope.folderWorkspaceId,
+ attached.hostId ? { executionHostId: attached.hostId } : undefined
+ )
+ : activateAndRevealWorktree(
+ attached.id,
+ attached.hostId ? { executionHostId: attached.hostId } : {}
+ )
+ if (activation === false) {
+ toast.error(
+ translate(
+ 'auto.lib.linear.issue.workspace.open.4f2c1d8a3b',
+ 'Unable to open the workspace attached to this issue.'
+ )
+ )
+ return 'failed'
+ }
+ return 'opened'
+}
diff --git a/src/renderer/src/lib/worktree-attachment-label.ts b/src/renderer/src/lib/worktree-attachment-label.ts
new file mode 100644
index 000000000..e79a83521
--- /dev/null
+++ b/src/renderer/src/lib/worktree-attachment-label.ts
@@ -0,0 +1,31 @@
+import type { Worktree } from '../../../shared/types'
+import { basename } from './path'
+
+/** Shared by every "workspace attached to this work item" surface so GitHub, GitLab,
+ * and Linear rows can't drift into labelling the same worktree differently. */
+export function getWorktreeAttachmentLabel(worktree: Worktree): string {
+ const displayName = worktree.displayName.trim()
+ if (displayName) {
+ return displayName
+ }
+
+ const branch = getBranchLabel(worktree.branch)
+ if (branch) {
+ return branch
+ }
+
+ return basename(worktree.path) || worktree.path
+}
+
+function getBranchLabel(branch: string | null | undefined): string | null {
+ const trimmed = branch?.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ if (trimmed.startsWith('refs/heads/')) {
+ return trimmed.slice('refs/heads/'.length)
+ }
+
+ return trimmed
+}
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index d926a100f..b738f18f3 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -319,7 +319,8 @@ const VALID_LINEAR_PRESETS = new Set>([
'issues',
'projects',
- 'views'
+ 'views',
+ 'in-orca'
])
const VALID_JIRA_PRESETS = new Set>([
'assigned',
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 0e6d3e419..947786da5 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -3317,7 +3317,7 @@ export type TaskResumeState = {
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
githubProjectHiddenFieldIdsByView?: Record
- linearMode?: 'issues' | 'projects' | 'views'
+ linearMode?: 'issues' | 'projects' | 'views' | 'in-orca'
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
linearContext?: {