OPE-296: Add linear integration for starting sessions from issues (#3235)
* feat(linear): start sessions from Linear issues Authorize a Linear workspace on this OpenChamber server, map teams to projects, attach an issue from chat, start a session or worktree from an issue, and post started/completed/failed comments that open the session. Hidden in VS Code. * feat(linear): connect more than one Linear workspace Store each OAuth grant on this OpenChamber server and keep one current, so Settings can add and switch workspaces without dropping the others. Project mapping is per workspace. Remove the Linear button next to New Chat; start-from-issue stays on New Worktree. * feat(linear): add a right-hand issues panel Browse and filter issues in the rail, open a card to change status or start a session, and collapse search plus most filters to icons on a narrow panel. * feat(linear): open issues in the rail and filter by Linear status The rail icon only shows after Linear is connected. Clicking a Linear row on work status opens the panel. Status options match the card, including Done, Canceled, and Duplicate. The Integrations experimental warning sits under Third-party integrations. * fix(linear): use stable OAuth callback broker * fix(chat): preview Linear issue attachments The context switch missed linear-issue, so tsc treated the preview helpers as incomplete. * fix(ui): restore Linear i18n parity and the #2903 sync harness Turkish was missing the Linear dictionaries, and the subagent test still wrapped only SyncContext after reads moved to SyncRuntimeContext. * fix(linear): drop changelog hunks and close review races Keep changelogs out of this PR, restore CodeMirror ranges, ignore stale Linear list pages, and leave a persisted Linear tab open until auth has actually resolved. * fix(linear): tint active issue filters and clear them in one click * fix(markdown): read escaped brackets as text, not display math `\[...\]` is display math in LaTeX and an escaped bracket pair in CommonMark. The block tokenizer claimed every `\[`, so prose like `[title \[Bug\] more](url)` was handed to KaTeX: "Bug" rendered as a centered formula and the block token split the paragraph, tearing the link into three pieces. Linear, GitHub and any other source that escapes brackets the way CommonMark requires hit this. Display math now has to own its line — `\[` starts one and `\]` ends one. A formula on its own line still renders; `\[` mid-sentence stays an escape, which is what CommonMark says it is and what prose almost always means. Inline `\(...\)` keeps the same ambiguity, but inline math is legitimately mid-sentence, so there is no position to judge it by. Covered by regression tests, including the verbatim comment body that surfaced this. * feat(linear): make session status comments opt-in and public-only A status comment lands in a Linear workspace the whole team reads, and the link it carried pointed at whatever origin started the session — usually loopback or a LAN address. Everyone but its author got a dead link, and nobody had agreed to the comments in the first place. Comments are now off until the user turns them on in Settings -> Integrations -> Linear, and the check lives on the server: the event hub posts completed and failure without going through the interface, so a client-side gate would not hold. When the resolved origin is not publicly reachable the server posts nothing at all rather than a link only its author can open; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The desktop deep-link origin is gone with it, since no one else can follow one either. The comment body also dropped the session title. It repeated the issue the comment already sits on, and issue titles routinely carry brackets ("[Bug] ...") that broke the markdown link. The body is now one short link, and `sessionTitle` is gone from the route, client and types. Also caps the dedupe file at the newest 500 sessions; it grew forever. * fix(linear): match the pull request panel and clear review findings Comments in the Linear panel now render as the same avatar timeline the pull request panel uses, with the shared time-format preference instead of a raw locale string. Comment authors carry `avatarUrl`, which the GraphQL selection was not requesting. Review findings from the same pass: - `status-runtime.js` hand-rolled `typeof` narrowing and failed the vendored anti-slop lint; it now parses through `parse.js` like every other file in the module. - `useLinearAuthStore` turned any failed request into `connected: false` with `hasChecked: true`. Since the rail icon, the composer entry and the worktree option all gate on `connected === true`, one network blip hid Linear for the rest of the session, and Settings only re-checked when it had never checked. It now keeps the last known status and leaves `hasChecked` false so the next caller retries. - `LinearIssuesView` (1096 lines) was a static import in `ContextPanel`, shipping in the main bundle although its rail icon stays hidden until a workspace is connected. It is lazy now, like `GitView`. - Dropped dead code: the unused port helpers left over from the loopback callback, two re-exported default values nothing read, and a redundant export in `linkedIssues`. - Integrations is no longer badged beta.
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { buildIssueContextText, startLinearIssueSession } from '@/lib/linearStartSession';
|
||||
import type { LinearIssueSummary, LinearMappingResult } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
const parseLinearIssueQuery = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const urlMatch = trimmed.match(/linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i);
|
||||
if (urlMatch) return urlMatch[1].toUpperCase();
|
||||
if (/^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmed)) return trimmed.toUpperCase();
|
||||
return null;
|
||||
};
|
||||
|
||||
export function LinearIssuePickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode = 'select',
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode?: 'createSession' | 'select';
|
||||
onSelect?: (issue: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { linear } = useRuntimeAPIs();
|
||||
const linearAuthStatus = useLinearAuthStore((state) => state.status);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { isTablet } = useDeviceInfo();
|
||||
const alwaysShowActions = isMobile || isTablet;
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [issues, setIssues] = React.useState<LinearIssueSummary[]>([]);
|
||||
const [cursor, setCursor] = React.useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const [connected, setConnected] = React.useState(true);
|
||||
const [startingIssueKey, setStartingIssueKey] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||
const [mapping, setMapping] = React.useState<LinearMappingResult | null>(null);
|
||||
const [mappingError, setMappingError] = React.useState<string | null>(null);
|
||||
const listRequestId = React.useRef(0);
|
||||
|
||||
const directIdentifier = React.useMemo(() => parseLinearIssueQuery(query), [query]);
|
||||
const debouncedQuery = useDebouncedValue(query, 350);
|
||||
|
||||
const refresh = React.useCallback(async (search = '') => {
|
||||
if (linearAuthChecked && linearAuthStatus?.connected === false) {
|
||||
setConnected(false);
|
||||
setIssues([]);
|
||||
setHasMore(false);
|
||||
setCursor(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (!linear?.issuesList) {
|
||||
setConnected(true);
|
||||
setError(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = listRequestId.current + 1;
|
||||
listRequestId.current = requestId;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await linear.issuesList(search ? { query: search } : undefined);
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setConnected(next.connected !== false);
|
||||
setIssues(next.issues ?? []);
|
||||
setCursor(next.cursor ?? null);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
} catch (e) {
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (requestId === listRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [linear, linearAuthChecked, linearAuthStatus, t]);
|
||||
|
||||
const refreshMapping = React.useCallback(async () => {
|
||||
if (mode !== 'createSession') {
|
||||
setMapping(null);
|
||||
setMappingError(null);
|
||||
return;
|
||||
}
|
||||
if (!linear?.mappingGet) {
|
||||
setMapping(null);
|
||||
setMappingError(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await linear.mappingGet();
|
||||
setMapping(next);
|
||||
setMappingError(null);
|
||||
} catch (e) {
|
||||
setMapping(null);
|
||||
setMappingError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [linear, mode, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
setStartingIssueKey(null);
|
||||
setError(null);
|
||||
setIssues([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
setIsLoading(false);
|
||||
setConnected(true);
|
||||
setCreateInWorktree(false);
|
||||
setMapping(null);
|
||||
setMappingError(null);
|
||||
return;
|
||||
}
|
||||
if (linear && !linearAuthChecked) {
|
||||
void refreshStatus(linear);
|
||||
}
|
||||
}, [open, linear, linearAuthChecked, refreshStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void refresh(debouncedQuery.trim());
|
||||
}, [open, debouncedQuery, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
void refreshMapping();
|
||||
}, [open, refreshMapping]);
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (!linear?.issuesList) return;
|
||||
if (isLoadingMore || isLoading) return;
|
||||
if (!hasMore || !cursor) return;
|
||||
|
||||
const requestId = listRequestId.current + 1;
|
||||
listRequestId.current = requestId;
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const search = debouncedQuery.trim();
|
||||
const next = await linear.issuesList({
|
||||
query: search || undefined,
|
||||
cursor,
|
||||
});
|
||||
if (requestId !== listRequestId.current) return;
|
||||
setConnected(next.connected !== false);
|
||||
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
|
||||
setCursor(next.cursor ?? null);
|
||||
setHasMore(Boolean(next.hasMore));
|
||||
} catch (e) {
|
||||
if (requestId !== listRequestId.current) return;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('session.linearIssuePicker.toast.loadMoreFailed'), { description: message });
|
||||
} finally {
|
||||
if (requestId === listRequestId.current) {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [cursor, debouncedQuery, hasMore, isLoading, isLoadingMore, linear, t]);
|
||||
|
||||
const openLinearSettings = React.useCallback(() => {
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
const selectIssue = React.useCallback(async (issueKey: string) => {
|
||||
if (!linear?.issueGet) {
|
||||
toast.error(t('session.linearIssuePicker.error.runtimeUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (startingIssueKey) return;
|
||||
setStartingIssueKey(issueKey);
|
||||
try {
|
||||
const issueRes = await linear.issueGet(issueKey);
|
||||
if (issueRes.connected === false) {
|
||||
toast.error(t('session.linearIssuePicker.error.notConnected'));
|
||||
return;
|
||||
}
|
||||
const issue = issueRes.issue;
|
||||
if (!issue) {
|
||||
toast.error(t('session.linearIssuePicker.error.issueNotFound'));
|
||||
return;
|
||||
}
|
||||
const comments = issue.comments ?? [];
|
||||
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||||
onSelect?.({
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
contextText: buildIssueContextText({ issue, comments }),
|
||||
author: login
|
||||
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||||
: undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
toast.error(t('session.linearIssuePicker.toast.loadIssueDetailsFailed'), { description: message });
|
||||
} finally {
|
||||
setStartingIssueKey(null);
|
||||
}
|
||||
}, [linear, onOpenChange, onSelect, startingIssueKey, t]);
|
||||
|
||||
const startSession = React.useCallback(async (issueKey: string) => {
|
||||
if (startingIssueKey) return;
|
||||
setStartingIssueKey(issueKey);
|
||||
try {
|
||||
await startLinearIssueSession({
|
||||
linear,
|
||||
issueKey,
|
||||
createInWorktree,
|
||||
mapping,
|
||||
onMappingLoaded: (next) => {
|
||||
setMapping(next);
|
||||
setMappingError(null);
|
||||
},
|
||||
onSessionCreated: () => onOpenChange(false),
|
||||
t,
|
||||
});
|
||||
} finally {
|
||||
setStartingIssueKey(null);
|
||||
}
|
||||
}, [createInWorktree, linear, mapping, onOpenChange, startingIssueKey, t]);
|
||||
|
||||
const handleIssue = React.useCallback((issueKey: string) => {
|
||||
if (mode === 'select') {
|
||||
void selectIssue(issueKey);
|
||||
return;
|
||||
}
|
||||
void startSession(issueKey);
|
||||
}, [mode, selectIssue, startSession]);
|
||||
|
||||
const title = mode === 'select'
|
||||
? t('session.linearIssuePicker.title')
|
||||
: t('session.linearIssuePicker.title.createSession');
|
||||
const description = mode === 'select'
|
||||
? t('session.linearIssuePicker.description')
|
||||
: t('session.linearIssuePicker.description.createSession');
|
||||
const showDisconnected = linearAuthChecked && connected === false;
|
||||
const runtimeMissing = !linear;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="relative mt-2">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('session.linearIssuePicker.searchPlaceholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
|
||||
{runtimeMissing ? (
|
||||
<div className="text-center text-muted-foreground py-8">{t('session.linearIssuePicker.empty.runtimeUnavailable')}</div>
|
||||
) : null}
|
||||
|
||||
{mode === 'createSession' && mappingError ? (
|
||||
<div className="text-center text-muted-foreground py-8 break-words">{mappingError}</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
{t('session.linearIssuePicker.loading.issues')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showDisconnected ? (
|
||||
<div className="text-center text-muted-foreground py-8 space-y-3">
|
||||
<div>{t('session.linearIssuePicker.empty.notConnected')}</div>
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={openLinearSettings}>
|
||||
{t('session.linearIssuePicker.actions.openSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
|
||||
) : null}
|
||||
|
||||
{directIdentifier && linear && connected ? (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
startingIssueKey === directIdentifier && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => handleIssue(directIdentifier)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
|
||||
{directIdentifier}
|
||||
</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
{t('session.linearIssuePicker.actions.useIssue', { identifier: directIdentifier })}
|
||||
</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{startingIssueKey === directIdentifier ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issues.length === 0 && !isLoading && connected && linear ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{debouncedQuery.trim()
|
||||
? t('session.linearIssuePicker.empty.noIssuesFound')
|
||||
: t('session.linearIssuePicker.empty.noOpenIssuesFound')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
|
||||
startingIssueKey === issue.id && 'bg-interactive-selection/30'
|
||||
)}
|
||||
onClick={() => handleIssue(issue.id)}
|
||||
>
|
||||
<span className="typography-meta text-muted-foreground w-16 text-right flex-shrink-0">
|
||||
{issue.identifier}
|
||||
</span>
|
||||
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
|
||||
{issue.title}
|
||||
</p>
|
||||
<div className="flex-shrink-0 h-5 flex items-center mr-2">
|
||||
{startingIssueKey === issue.id ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<a
|
||||
href={issue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors',
|
||||
alwaysShowActions ? 'flex' : 'hidden group-hover:flex'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t('session.linearIssuePicker.actions.openInLinearAria')}
|
||||
>
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMore && connected && linear ? (
|
||||
<div className="py-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadMore()}
|
||||
disabled={isLoadingMore || Boolean(startingIssueKey)}
|
||||
className={cn(
|
||||
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
|
||||
(isLoadingMore || Boolean(startingIssueKey)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
{t('session.linearIssuePicker.loading.more')}
|
||||
</span>
|
||||
) : (
|
||||
t('session.linearIssuePicker.actions.loadMore')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mode !== 'select' ? (
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
|
||||
<p className="typography-meta text-muted-foreground font-medium mb-2">{t('session.linearIssuePicker.actions.sectionTitle')}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={createInWorktree}
|
||||
onClick={() => setCreateInWorktree((value) => !value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setCreateInWorktree((value) => !value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCreateInWorktree((value) => !value);
|
||||
}}
|
||||
aria-label={t('session.linearIssuePicker.actions.toggleWorktreeAria')}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{createInWorktree ? (
|
||||
<Icon name="checkbox" className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Icon name="checkbox-blank" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<span className="typography-meta text-muted-foreground">{t('session.linearIssuePicker.actions.createInWorktree')}</span>
|
||||
</div>
|
||||
<div className="hidden sm:block sm:flex-1" />
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh(debouncedQuery.trim())} disabled={isLoading || Boolean(startingIssueKey)}>
|
||||
{t('session.linearIssuePicker.actions.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={() => onOpenChange(false)}
|
||||
renderHeader={(closeButton) => (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
|
||||
{closeButton}
|
||||
</div>
|
||||
<p className="typography-small text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</MobileOverlayPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="linear" className="h-5 w-5" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{content}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,12 @@ import { cn } from '@/lib/utils';
|
||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
@@ -40,6 +41,7 @@ import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/ope
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import { postLinearSessionStarted } from '@/lib/linearSessionStatus';
|
||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||
import { LinearIssuePickerDialog } from './LinearIssuePickerDialog';
|
||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -59,6 +62,8 @@ import type {
|
||||
GitHubIssuesListResult,
|
||||
GitHubPullRequestContextResult,
|
||||
GitHubPullRequestSummary,
|
||||
LinearIssue,
|
||||
LinearIssueComment,
|
||||
} from '@/lib/api/types';
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -72,6 +77,13 @@ interface ValidationState {
|
||||
touched: boolean;
|
||||
}
|
||||
|
||||
type LinkedLinearWorktreeIssue = {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
};
|
||||
|
||||
// State for New Branch mode
|
||||
interface NewBranchState {
|
||||
branchName: string;
|
||||
@@ -80,6 +92,7 @@ interface NewBranchState {
|
||||
sourceBranch: string;
|
||||
linkedIssue: GitHubIssue | null;
|
||||
linkedPr: GitHubPullRequestSummary | null;
|
||||
linkedLinearIssue: LinkedLinearWorktreeIssue | null;
|
||||
includePrDiff: boolean;
|
||||
}
|
||||
|
||||
@@ -209,16 +222,29 @@ const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) =>
|
||||
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
const buildLinearIssueContextText = (args: {
|
||||
issue: LinearIssue;
|
||||
comments: LinearIssueComment[];
|
||||
}) => {
|
||||
const payload = {
|
||||
issue: args.issue,
|
||||
comments: args.comments,
|
||||
};
|
||||
return `Linear issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
||||
};
|
||||
|
||||
export function NewWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onWorktreeCreated,
|
||||
}: NewWorktreeDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { github, git } = useRuntimeAPIs();
|
||||
const { github, git, linear } = useRuntimeAPIs();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const linearAuthStatus = useLinearAuthStore((state) => state.status);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
@@ -240,6 +266,7 @@ export function NewWorktreeDialog({
|
||||
sourceBranch: '',
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
});
|
||||
|
||||
@@ -290,6 +317,7 @@ export function NewWorktreeDialog({
|
||||
}, [existingWorktreeNames]);
|
||||
|
||||
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
||||
const [linearDialogOpen, setLinearDialogOpen] = React.useState(false);
|
||||
|
||||
// Desktop branch picker states
|
||||
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
||||
@@ -480,12 +508,9 @@ export function NewWorktreeDialog({
|
||||
directory: string;
|
||||
issue: GitHubIssue | null;
|
||||
pr: GitHubPullRequestSummary | null;
|
||||
linearIssue: LinkedLinearWorktreeIssue | null;
|
||||
includeDiff: boolean;
|
||||
}) => {
|
||||
if (!projectDirectory || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
const configState = useConfigStore.getState();
|
||||
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
||||
const defaultModel = resolveDefaultModelSelection();
|
||||
@@ -500,6 +525,69 @@ export function NewWorktreeDialog({
|
||||
|
||||
const variant = resolveDefaultVariant(providerID, modelID);
|
||||
|
||||
if (args.linearIssue) {
|
||||
if (!linear?.issueGet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueRes = await linear.issueGet(args.linearIssue.identifier);
|
||||
if (issueRes.connected === false || !issueRes.issue) {
|
||||
throw new Error('Failed to load issue context');
|
||||
}
|
||||
|
||||
const issue = issueRes.issue;
|
||||
const comments = issue.comments ?? [];
|
||||
const login = issue.assignee?.displayName || issue.assignee?.name;
|
||||
const visiblePromptText = await renderMagicPrompt('linear.issue.review.visible', {
|
||||
identifier: issue.identifier,
|
||||
});
|
||||
const instructionsText = await renderMagicPrompt('linear.issue.review.instructions');
|
||||
const contextText = buildLinearIssueContextText({ issue, comments });
|
||||
|
||||
postLinearSessionStarted(linear, {
|
||||
sessionId: args.sessionId,
|
||||
issueIdentifier: issue.identifier,
|
||||
});
|
||||
|
||||
await useSessionUIStore.getState().sendMessage(
|
||||
visiblePromptText,
|
||||
providerID,
|
||||
modelID,
|
||||
agentName,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{ text: instructionsText, synthetic: true },
|
||||
{ text: contextText, synthetic: true },
|
||||
],
|
||||
variant,
|
||||
undefined,
|
||||
{ sessionId: args.sessionId, directory: args.directory },
|
||||
);
|
||||
|
||||
void sessionActions.setLinkedIssue(
|
||||
args.sessionId,
|
||||
args.directory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: login
|
||||
? { login, avatarUrl: issue.assignee?.avatarUrl || undefined }
|
||||
: args.linearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
|
||||
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectDirectory || !github) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.issue) {
|
||||
if (!github.issueGet || !github.issueComments) {
|
||||
return;
|
||||
@@ -615,6 +703,7 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
}, [
|
||||
github,
|
||||
linear,
|
||||
projectDirectory,
|
||||
resolveDefaultAgentName,
|
||||
resolveDefaultModelSelection,
|
||||
@@ -702,6 +791,7 @@ export function NewWorktreeDialog({
|
||||
sourceBranch: '',
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
});
|
||||
}, [open, generateUniqueSlug]);
|
||||
@@ -862,9 +952,10 @@ export function NewWorktreeDialog({
|
||||
try {
|
||||
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
||||
const linkedLinearIssue = mode === 'new-branch' ? newBranchState.linkedLinearIssue : null;
|
||||
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||||
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState);
|
||||
const shouldCreateSession = Boolean(linkedIssue || linkedPrState || linkedLinearIssue);
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const sourceBranch = newBranchState.sourceBranch;
|
||||
@@ -914,7 +1005,9 @@ export function NewWorktreeDialog({
|
||||
await waitForWorktreeBootstrap(metadata.path);
|
||||
}
|
||||
|
||||
const sessionTitle = linkedIssue
|
||||
const sessionTitle = linkedLinearIssue
|
||||
? `${linkedLinearIssue.identifier} ${linkedLinearIssue.title}`.trim()
|
||||
: linkedIssue
|
||||
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
||||
: linkedPrState
|
||||
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
||||
@@ -966,10 +1059,14 @@ export function NewWorktreeDialog({
|
||||
directory: metadata.path,
|
||||
issue: linkedIssue,
|
||||
pr: linkedPrState,
|
||||
linearIssue: linkedLinearIssue,
|
||||
includeDiff: includePrDiff,
|
||||
}).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed');
|
||||
toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message });
|
||||
const fallback = linkedLinearIssue
|
||||
? t('session.newWorktree.error.sendLinearContextFailed')
|
||||
: t('session.newWorktree.error.sendGitHubContextFailed');
|
||||
const message = error instanceof Error ? error.message : fallback;
|
||||
toast.error(fallback, { description: message });
|
||||
});
|
||||
} else {
|
||||
onWorktreeCreated?.(metadata.path);
|
||||
@@ -999,6 +1096,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
branchName: '',
|
||||
}));
|
||||
@@ -1012,6 +1110,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: issue,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
@@ -1023,6 +1122,7 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedPr: pr,
|
||||
linkedIssue: null,
|
||||
linkedLinearIssue: null,
|
||||
includePrDiff: result.includeDiff ?? false,
|
||||
branchName: pr.head,
|
||||
worktreeName: slugifyWorktreeName(pr.head),
|
||||
@@ -1031,8 +1131,33 @@ export function NewWorktreeDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinearSelect = (issue: {
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
}) => {
|
||||
const newBranchName = `issue-${issue.identifier}-${generateBranchSlug()}`;
|
||||
setNewBranchState(prev => ({
|
||||
...prev,
|
||||
linkedLinearIssue: {
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
author: issue.author,
|
||||
},
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
includePrDiff: false,
|
||||
branchName: newBranchName,
|
||||
worktreeName: slugifyWorktreeName(newBranchName),
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
};
|
||||
|
||||
// GitHub connection check
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
const isLinearConnected = Boolean(linear) && linearAuthChecked && linearAuthStatus?.connected === true;
|
||||
|
||||
// Check if form is valid for submission
|
||||
const isFormValid = mode === 'existing-branch'
|
||||
@@ -1046,12 +1171,42 @@ export function NewWorktreeDialog({
|
||||
...prev,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
branchName: '',
|
||||
includePrDiff: false,
|
||||
isSyncingWorktreeName: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const startFromIssueButtons = mode === 'new-branch' && (isGitHubConnected || isLinearConnected) ? (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{isGitHubConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="h-8 w-8 px-0"
|
||||
title={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
aria-label={t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
</Button>
|
||||
)}
|
||||
{isLinearConnected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLinearDialogOpen(true)}
|
||||
className="h-8 w-8 px-0"
|
||||
title={t('session.newWorktree.actions.startFromLinearIssue')}
|
||||
aria-label={t('session.newWorktree.actions.startFromLinearIssue')}
|
||||
>
|
||||
<Icon name="linear" className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Footer content
|
||||
const footerContent = (
|
||||
<div className={cn('flex gap-2', isMobile ? 'flex-col w-full' : 'flex-row items-center')}>
|
||||
@@ -1277,21 +1432,11 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{startFromIssueButtons}
|
||||
</div>
|
||||
<Input
|
||||
value={newBranchState.branchName}
|
||||
@@ -1302,6 +1447,7 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
@@ -1329,6 +1475,17 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromLinearIssue', {
|
||||
identifier: newBranchState.linkedLinearIssue.identifier,
|
||||
title: newBranchState.linkedLinearIssue.title,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1527,12 +1684,23 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<Icon
|
||||
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
||||
)}
|
||||
/>
|
||||
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{newBranchState.linkedLinearIssue.identifier}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
||||
@@ -1545,11 +1713,11 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -1745,21 +1913,11 @@ export function NewWorktreeDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="typography-ui-label text-foreground block font-semibold">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="typography-ui-label text-foreground font-semibold shrink-0">
|
||||
{t('session.newWorktree.branchName')}
|
||||
</label>
|
||||
{mode === 'new-branch' && isGitHubConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGithubDialogOpen(true)}
|
||||
className="gap-1.5 h-7"
|
||||
>
|
||||
<Icon name="github" className="size-4 text-status-success" />
|
||||
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
||||
</Button>
|
||||
)}
|
||||
{startFromIssueButtons}
|
||||
</div>
|
||||
<Input
|
||||
value={newBranchState.branchName}
|
||||
@@ -1770,6 +1928,7 @@ export function NewWorktreeDialog({
|
||||
isSyncingWorktreeName: true,
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
}));
|
||||
}}
|
||||
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
||||
@@ -1797,6 +1956,17 @@ export function NewWorktreeDialog({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
||||
<span className="typography-micro">
|
||||
{t('session.newWorktree.fromLinearIssue', {
|
||||
identifier: newBranchState.linkedLinearIssue.identifier,
|
||||
title: newBranchState.linkedLinearIssue.title,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1968,12 +2138,23 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
{/* Linked Item Preview - Two row minimal display */}
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
||||
{(newBranchState.linkedIssue || newBranchState.linkedPr || newBranchState.linkedLinearIssue) && mode === 'new-branch' && (
|
||||
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
||||
{/* Row 1: Type, number, title, actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||||
<Icon
|
||||
name={newBranchState.linkedLinearIssue ? 'linear' : 'github'}
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0',
|
||||
newBranchState.linkedLinearIssue ? 'text-foreground' : 'text-status-success',
|
||||
)}
|
||||
/>
|
||||
|
||||
{newBranchState.linkedLinearIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{newBranchState.linkedLinearIssue.identifier}
|
||||
</span>
|
||||
)}
|
||||
{newBranchState.linkedIssue && (
|
||||
<span className="typography-micro text-muted-foreground shrink-0">
|
||||
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
||||
@@ -1986,11 +2167,11 @@ export function NewWorktreeDialog({
|
||||
)}
|
||||
|
||||
<span className="typography-micro text-foreground truncate flex-1">
|
||||
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
{newBranchState.linkedLinearIssue?.title || newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
||||
</span>
|
||||
|
||||
<a
|
||||
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
href={newBranchState.linkedLinearIssue?.url || newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||
@@ -2067,6 +2248,12 @@ export function NewWorktreeDialog({
|
||||
onOpenChange={setGithubDialogOpen}
|
||||
onSelect={handleGitHubSelect}
|
||||
/>
|
||||
<LinearIssuePickerDialog
|
||||
open={linearDialogOpen}
|
||||
onOpenChange={setLinearDialogOpen}
|
||||
mode="select"
|
||||
onSelect={handleLinearSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user