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.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 ? ( + + ) : ( +
+ {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 */} -
- +
+ {attachedWorkspaceLabel ? ( +
+ + {attachedWorkspaceLabel} +
+ ) : null} + {attachedWorkspace ? ( + + + + + + + + + onUse(displayed)}> + + {translate( + 'auto.components.LinearItemDrawer.startNewWorkspace', + 'Start new workspace' + )} + + + + ) : ( + + )}
)} 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 ( + + + + + + {translate( + 'auto.components.TaskPage.linearModeHasWorktreeTooltip', + 'Linear tickets linked to an Orca workspace' + )} + + + ) + } return ( @@ -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 (
-
- +
+ + + + + + {attachedWorkspace + ? translate('auto.components.TaskPage.606a85c774', 'Open') + : translate( + 'auto.components.TaskPage.7d08e8be0f', + 'Start' + )} + +
{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} -
+
- {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?: {