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 825248ac4e7..f0a8e814e7c 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 ccb1f03c0e5..690f2d42655 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 719aecbf4b1..82053896d36 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 0f2fa5c12fb..2a998577fbd 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 00000000000..28804568fec
--- /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 00000000000..3db29f31846
--- /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 3d0215785bb..c4498969de3 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 9745a19232c..53c5af930ad 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 dbe94a85ab4..095de652a6a 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 830e17809cd..4ed7237ae84 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 4d1ed1e286e..bb5bbc4b4da 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 a76a2b01b06..e7529643e5e 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 a3ac4e3d9a6..7a31c32a743 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 00000000000..94455731201
--- /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 00000000000..afea291b21f
--- /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 00000000000..0d6dd0211bc
--- /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 00000000000..4b838176f44
--- /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 00000000000..e79a83521f1
--- /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 d926a100f2f..b738f18f327 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 0e6d3e41983..947786da5bc 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?: {