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.
This commit is contained in:
Jinjing 2026-08-04 22:12:00 -07:00 committed by GitHub
parent bac99c920b
commit eebaf47df0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1645 additions and 138 deletions

View File

@ -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

View File

@ -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<LinearIssue | null>(null)
const [issueLoading, setIssueLoading] = useState(false)
const [comments, setComments] = useState<LinearComment[]>([])
@ -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')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleUseIssue}
aria-label={translate(
'auto.components.LinearIssueWorkspace.30a7f56c0a',
'Start workspace from issue'
)}
>
<ArrowRight className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')}
</TooltipContent>
</Tooltip>
{attachedWorkspace ? (
<DropdownMenu modal={false}>
<ButtonGroup>
<Button
type="button"
size="sm"
onClick={handleOpenOrUseIssue}
className="gap-1.5 whitespace-nowrap"
aria-label={translate(
'auto.components.LinearIssueWorkspace.openAttachedWorkspace',
'Open workspace attached to issue'
)}
>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.LinearIssueWorkspace.openWorkspace',
'Open workspace'
)}
</Button>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-sm"
aria-label={translate(
'auto.components.LinearIssueWorkspace.moreWorkspaceActions',
'More issue workspace actions'
)}
>
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</ButtonGroup>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleUseIssue}>
<Plus className="size-4" />
{translate(
'auto.components.LinearIssueWorkspace.startNewWorkspace',
'Start new workspace'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={handleOpenOrUseIssue}
aria-label={translate(
'auto.components.LinearIssueWorkspace.30a7f56c0a',
'Start workspace from issue'
)}
>
<ArrowRight className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.LinearIssueWorkspace.e1e0a9bca9', 'Start workspace')}
</TooltipContent>
</Tooltip>
)}
{variant === 'sheet' ? (
<Tooltip>
<TooltipTrigger asChild>
@ -949,6 +1029,33 @@ export default function LinearIssueWorkspace({
onProjectChanged={handleProjectChanged}
sourceContext={sourceContext}
/>
<section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs">
<div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground">
<span>
{translate('auto.components.LinearIssueWorkspace.workspaceSection', 'Workspace')}
</span>
</div>
<div className="p-3">
{attachedWorkspaceLabel ? (
<button
type="button"
onClick={handleOpenOrUseIssue}
aria-label={translate(
'auto.components.LinearIssueWorkspace.openAttachedWorkspace',
'Open workspace attached to issue'
)}
className="flex min-h-9 w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<FolderOpen className="size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{attachedWorkspaceLabel}</span>
</button>
) : (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
{translate('auto.components.LinearIssueWorkspace.noWorkspaceYet', 'None yet')}
</div>
)}
</div>
</section>
<section className="rounded-xl border border-border/60 bg-card text-card-foreground shadow-xs">
<div className="flex h-10 items-center gap-1 border-b border-border/50 px-4 text-sm font-medium text-muted-foreground">
<span>

View File

@ -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<LinearComment[]>([])
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<LinearEditState>) => {
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 (
<Sheet open={issue !== null} onOpenChange={(open) => !open && onClose()}>
@ -1526,28 +1562,75 @@ export default function LinearItemDrawer({
</div>
</div>
{/* Comment footer + Start workspace */}
{/* Comment footer + Start/Open workspace */}
<LinearIssueCommentFooter
issueId={displayed.id}
workspaceId={displayed.workspaceId}
onCommentAdded={handleCommentAdded}
sourceContext={sourceContext}
/>
<div className="flex-none border-t border-border/60 bg-background/40 px-4 py-3">
<Button
onClick={() => 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'
)}
<ArrowRight className="size-4" />
</Button>
<div className="flex-none space-y-2 border-t border-border/60 bg-background/40 px-4 py-3">
{attachedWorkspaceLabel ? (
<div className="flex min-w-0 items-center gap-1.5 text-[12px] text-muted-foreground">
<FolderOpen className="size-3.5 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</div>
) : null}
{attachedWorkspace ? (
<DropdownMenu modal={false}>
<ButtonGroup className="w-full">
<Button
onClick={handleOpenOrUseIssue}
className="flex-1 justify-center gap-2"
aria-label={translate(
'auto.components.LinearItemDrawer.openAttachedWorkspace',
'Open workspace attached to issue'
)}
>
<FolderOpen className="size-4" />
{translate(
'auto.components.LinearItemDrawer.openWorkspace',
'Open workspace'
)}
</Button>
<DropdownMenuTrigger asChild>
<Button
size="icon"
aria-label={translate(
'auto.components.LinearItemDrawer.moreWorkspaceActions',
'More issue workspace actions'
)}
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
</ButtonGroup>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onUse(displayed)}>
<Plus className="size-4" />
{translate(
'auto.components.LinearItemDrawer.startNewWorkspace',
'Start new workspace'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
onClick={handleOpenOrUseIssue}
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'
)}
<ArrowRight className="size-4" />
</Button>
)}
</div>
</div>
)}

View File

@ -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<LinearDisplay
if (visibleProperties.has('updated')) {
columns.push('104px')
}
// Why: Worktrees is icon-only (open vs start); keep it narrow so issue title keeps the room.
columns.push('64px')
return columns.join(' ')
}
@ -3094,8 +3110,11 @@ export default function TaskPage(): React.JSX.Element {
const searchLinearIssues = useAppStore((s) => 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 (
<Tooltip key={mode.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-pressed={active}
onClick={() => selectLinearMode(mode.id)}
className={buttonClassName}
>
{mode.label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.TaskPage.linearModeHasWorktreeTooltip',
'Linear tickets linked to an Orca workspace'
)}
</TooltipContent>
</Tooltip>
)
}
return (
<button
key={mode.id}
type="button"
aria-pressed={active}
onClick={() => 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}
</button>
@ -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 ? (
<LoaderCircle className="size-4 animate-spin" />
) : linearMode === 'projects' &&
(linearProjectsLoading || linearProjectDetailLoading) ? (
@ -8736,7 +8929,7 @@ export default function TaskPage(): React.JSX.Element {
</div>
</div>
{linearMode === 'issues' ? (
{linearMode === 'issues' || linearMode === 'in-orca' ? (
<div className="mt-3 flex min-w-0 items-center gap-2">
{showLinearAttributeFilters ? (
<LinearIssueAttributeFilterDropdowns
@ -8772,14 +8965,26 @@ export default function TaskPage(): React.JSX.Element {
const trimmed = linearSearchInput.trim()
setLinearSearchInput(trimmed)
setAppliedLinearSearch(trimmed)
setTaskResumeState({ linearQuery: trimmed, linearMode: 'issues' })
setLinearRefreshNonce((n) => 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}
<div className="min-w-0 text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{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'))}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
@ -10472,7 +10687,9 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('updated') ? (
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
) : null}
<span />
<span>
{translate('auto.components.TaskPage.linearWorktreesColumn', 'Workspaces')}
</span>
</div>
) : null}
@ -10527,6 +10744,18 @@ export default function TaskPage(): React.JSX.Element {
</p>
<p className="mt-2 text-sm text-muted-foreground">
{(() => {
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 ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{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'
)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{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.'
)}
</p>
{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 (
<div
key={issue.id}
@ -10683,23 +10927,48 @@ export default function TaskPage(): React.JSX.Element {
{issue.title}
</h3>
</div>
<div className="flex shrink-0 items-center gap-1 opacity-70 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100">
<Button
variant="ghost"
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
event.stopPropagation()
handleUseLinearItem(issue)
}}
aria-label={translate(
'auto.components.TaskPage.ff90d0abc7',
'Start workspace from {{value0}}',
{ value0: issue.identifier }
)}
>
<ArrowRight className="size-3.5" />
</Button>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
// Why: solid primary when a workspace is already linked so Open reads stronger than Start.
variant={attachedWorkspace ? 'default' : 'ghost'}
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
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 ? (
<FolderOpen className="size-3.5" />
) : (
<ArrowRight className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{attachedWorkspace
? translate('auto.components.TaskPage.606a85c774', 'Open')
: translate(
'auto.components.TaskPage.7d08e8be0f',
'Start'
)}
</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon-xs"
@ -10740,6 +11009,12 @@ export default function TaskPage(): React.JSX.Element {
{effectiveLinearDisplayProperties.has('updated') ? (
<span>{formatRelativeTime(issue.updatedAt)}</span>
) : null}
{attachedWorkspaceLabel ? (
<span className="inline-flex min-w-0 items-center gap-1">
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
) : null}
</div>
{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 (
<div
key={issue.id}
@ -10855,6 +11137,12 @@ export default function TaskPage(): React.JSX.Element {
{teamLabel}
</span>
) : null}
{attachedWorkspaceLabel ? (
<span className="inline-flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{attachedWorkspaceLabel}</span>
</span>
) : null}
</div>
</div>
@ -10935,28 +11223,46 @@ export default function TaskPage(): React.JSX.Element {
</Tooltip>
) : null}
<div className="flex shrink-0 items-center justify-end gap-1 md:opacity-0 md:transition-opacity md:group-hover/row:opacity-100 md:group-focus-within/row:opacity-100">
<div className="flex shrink-0 items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
type="button"
// Why: solid primary when a workspace is already linked so Open reads stronger than Start.
variant={attachedWorkspace ? 'default' : 'ghost'}
size="icon-xs"
data-contextual-tour-target="tasks-start-workspace"
onClick={(event) => {
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 }
)
}
>
<ArrowRight className="size-3.5" />
{attachedWorkspace ? (
<FolderOpen className="size-3.5" />
) : (
<ArrowRight className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
{attachedWorkspace
? (attachedWorkspaceLabel ??
translate('auto.components.TaskPage.606a85c774', 'Open'))
: translate('auto.components.TaskPage.7d08e8be0f', 'Start')}
</TooltipContent>
</Tooltip>
<Tooltip>

View File

@ -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<LinearIssue> & Pick<LinearIssue, 'id' | 'identifier'>
): 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))
})
})

View File

@ -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<Worktree, 'linkedLinearIssue'> &
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<LinearWorkspace, 'id' | 'organizationUrlKey'>[]
}
): LinkedLinearIssueRef[] {
const selectedWorkspaceId =
options?.workspaceId && options.workspaceId !== 'all' ? options.workspaceId : null
const workspaceIdByOrgKey = new Map<string, string>()
for (const workspace of options?.workspaces ?? []) {
if (workspace.organizationUrlKey) {
workspaceIdByOrgKey.set(workspace.organizationUrlKey.toLowerCase(), workspace.id)
}
}
const byIdentifier = new Map<string, LinkedLinearIssueRef[]>()
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<LinearIssue | null>,
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
}

View File

@ -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')
}
]
)

View File

@ -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": "Couldnt 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",

View File

@ -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",

View File

@ -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": "並べ替え",

View File

@ -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": "정렬 기준",

View File

@ -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": "排序方式",

View File

@ -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)
}

View File

@ -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> = {}): 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')
})
})

View File

@ -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<LinearIssue, 'identifier'> &
Partial<Pick<LinearIssue, 'workspaceId' | 'url'>>
/** Normalized identifier -> linking worktrees, in worktree order. */
export type LinearIssueWorkspaceAttachmentIndex = ReadonlyMap<string, readonly Worktree[]>
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<string, Worktree[]>()
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)
}

View File

@ -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> = {}): 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()
})
})

View File

@ -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'
}

View File

@ -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
}

View File

@ -319,7 +319,8 @@ const VALID_LINEAR_PRESETS = new Set<NonNullable<TaskResumeState['linearPreset']
const VALID_LINEAR_MODES = new Set<NonNullable<TaskResumeState['linearMode']>>([
'issues',
'projects',
'views'
'views',
'in-orca'
])
const VALID_JIRA_PRESETS = new Set<NonNullable<TaskResumeState['jiraPreset']>>([
'assigned',

View File

@ -3317,7 +3317,7 @@ export type TaskResumeState = {
githubItemsPreset?: TaskViewPresetId | null
githubItemsQuery?: string
githubProjectHiddenFieldIdsByView?: Record<string, string[]>
linearMode?: 'issues' | 'projects' | 'views'
linearMode?: 'issues' | 'projects' | 'views' | 'in-orca'
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
linearQuery?: string
linearContext?: {