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:
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/sync/attachment-files';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue } from '@/lib/linkedIssues';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
@@ -66,6 +66,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { LinearIssuePickerDialog } from '@/components/session/LinearIssuePickerDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { DraftPresetChips } from './DraftPresetChips';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
@@ -426,7 +427,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
||||
const { git: runtimeGit, vscode: vscodeApi } = useRuntimeAPIs();
|
||||
const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
|
||||
const cycleAgentShortcutOverride = useUIStore((state) => state.shortcutOverrides.cycle_agent);
|
||||
const cycleAgentShortcut = React.useMemo(() => (
|
||||
getEffectiveShortcutCombo('cycle_agent', cycleAgentShortcutOverride ? { cycle_agent: cycleAgentShortcutOverride } : undefined)
|
||||
@@ -722,6 +723,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linearPickerOpen, setLinearPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
@@ -740,6 +742,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const messageQueueTarget = currentSessionId
|
||||
@@ -972,6 +981,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
setPrPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const openLinearPicker = React.useCallback(() => {
|
||||
setLinearPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('runtime changed')
|
||||
@@ -1158,6 +1171,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
linkedPr: linkedPr
|
||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||
: null,
|
||||
linkedLinearIssue: linkedLinearIssue
|
||||
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
|
||||
: null,
|
||||
}, {
|
||||
parseAgentMention: (text) => {
|
||||
const { sanitizedText, mention } = parseAgentMentions(text, agents);
|
||||
@@ -1395,6 +1411,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (linkedLinearIssue && linkTargetSessionId) {
|
||||
void sessionActions.setLinkedIssue(
|
||||
linkTargetSessionId,
|
||||
linkTargetDirectory,
|
||||
buildLinkedLinearIssue({
|
||||
identifier: linkedLinearIssue.identifier,
|
||||
title: linkedLinearIssue.title,
|
||||
url: linkedLinearIssue.url,
|
||||
author: linkedLinearIssue.author,
|
||||
linkedAt: Date.now(),
|
||||
}),
|
||||
true,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
@@ -1403,6 +1433,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
if (linkedLinearIssue) {
|
||||
setLinkedLinearIssue(null);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -2528,6 +2561,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const showLinearPicker = Boolean(runtimeLinear) && !isVSCode;
|
||||
// The work-status panel carries the agent's todos and the changed-file
|
||||
// count, but only on the desktop/web layout — VS Code and mobile have no
|
||||
// panel, so these keep their place above the composer there.
|
||||
@@ -2608,6 +2642,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
draftPickerOpen: mobileDraftPicker !== null,
|
||||
issuePickerOpen,
|
||||
prPickerOpen,
|
||||
linearPickerOpen,
|
||||
isDragging,
|
||||
},
|
||||
});
|
||||
@@ -2778,6 +2813,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onRemove={() => setLinkedPr(null)}
|
||||
/>
|
||||
) : null}
|
||||
{linkedLinearIssue && !isVSCode ? (
|
||||
<LinkedReferenceRow
|
||||
numberLabel={linkedLinearIssue.identifier}
|
||||
title={linkedLinearIssue.title}
|
||||
url={linkedLinearIssue.url}
|
||||
author={linkedLinearIssue.author}
|
||||
openInBrowserLabel={t('chat.chatInput.linked.linearIssue.openInBrowserAria')}
|
||||
removeLabel={t('chat.chatInput.linked.linearIssue.removeAria')}
|
||||
onReopenPicker={() => setLinearPickerOpen(true)}
|
||||
onRemove={() => setLinkedLinearIssue(null)}
|
||||
/>
|
||||
) : null}
|
||||
<RevertedMessageDock
|
||||
sessionId={currentSessionId}
|
||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||
@@ -2843,6 +2890,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onStartDictation={toggleDictation}
|
||||
onAbort={handleAbort}
|
||||
@@ -3023,6 +3072,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onPickLocalFiles={handlePickLocalFiles}
|
||||
onOpenIssuePicker={openIssuePicker}
|
||||
onOpenPrPicker={openPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
onOpenLinearPicker={openLinearPicker}
|
||||
onOpenAttachSheet={openMobileAttachSheet}
|
||||
onToggleExpandedInput={handleToggleExpandedInput}
|
||||
onTogglePermissionAutoAccept={handlePermissionAutoAcceptToggle}
|
||||
@@ -3087,6 +3138,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(issue) => {
|
||||
setLinkedIssue(issue);
|
||||
setLinkedPr(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<GitHubPrPickerDialog
|
||||
@@ -3095,6 +3147,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
onSelect={(pr) => {
|
||||
setLinkedPr(pr);
|
||||
setLinkedIssue(null);
|
||||
setLinkedLinearIssue(null);
|
||||
}}
|
||||
/>
|
||||
<LinearIssuePickerDialog
|
||||
open={linearPickerOpen}
|
||||
onOpenChange={setLinearPickerOpen}
|
||||
mode="select"
|
||||
onSelect={(issue) => {
|
||||
setLinkedLinearIssue(issue);
|
||||
setLinkedIssue(null);
|
||||
setLinkedPr(null);
|
||||
}}
|
||||
/>
|
||||
<ReviewFlowDialog
|
||||
@@ -3178,6 +3241,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
<Icon name="git-pull-request" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</button>
|
||||
{showLinearPicker ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
mobileShell.skipNextOverlayCloseRestore();
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : null}
|
||||
|
||||
@@ -558,17 +558,29 @@ interface FilePart {
|
||||
|
||||
const GITHUB_ISSUE_LINK_MIME = 'application/vnd.github.issue-link';
|
||||
const GITHUB_PR_LINK_MIME = 'application/vnd.github.pull-request-link';
|
||||
const LINEAR_ISSUE_LINK_MIME = 'application/vnd.openchamber.linear-issue-link';
|
||||
|
||||
const getGitHubLinkKind = (file: FilePart): 'issue' | 'pr' | null => {
|
||||
type IssueLinkKind = 'github-issue' | 'github-pr' | 'linear-issue';
|
||||
|
||||
const getIssueLinkKind = (file: FilePart): IssueLinkKind | null => {
|
||||
if (file.mime === GITHUB_ISSUE_LINK_MIME) {
|
||||
return 'issue';
|
||||
return 'github-issue';
|
||||
}
|
||||
if (file.mime === GITHUB_PR_LINK_MIME) {
|
||||
return 'pr';
|
||||
return 'github-pr';
|
||||
}
|
||||
if (file.mime === LINEAR_ISSUE_LINK_MIME) {
|
||||
return 'linear-issue';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const issueLinkIcon = (kind: IssueLinkKind): 'github' | 'git-pull-request' | 'linear' => {
|
||||
if (kind === 'github-pr') return 'git-pull-request';
|
||||
if (kind === 'linear-issue') return 'linear';
|
||||
return 'github';
|
||||
};
|
||||
|
||||
interface MessageFilesDisplayProps {
|
||||
files: FilePart[];
|
||||
onShowPopup?: (content: ToolPopupContent) => void;
|
||||
@@ -591,7 +603,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
};
|
||||
|
||||
const resolveDisplayName = React.useCallback((file: FilePart): string => {
|
||||
const isGitHubLink = getGitHubLinkKind(file) !== null;
|
||||
const isGitHubLink = getIssueLinkKind(file) !== null;
|
||||
if (isGitHubLink && typeof file.filename === 'string' && file.filename.trim().length > 0) {
|
||||
return file.filename.trim();
|
||||
}
|
||||
@@ -665,11 +677,11 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
{githubLinkKind && file.url ? (
|
||||
{issueLinkKind && file.url ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -677,11 +689,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
}}
|
||||
className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Icon name="github" className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<div className="overflow-hidden max-w-[220px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
@@ -764,7 +772,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
const fileName = resolveDisplayName(file);
|
||||
const isImage = file.mime?.startsWith('image/');
|
||||
const sizeText = formatFileSize(file.size);
|
||||
const githubLinkKind = getGitHubLinkKind(file);
|
||||
const issueLinkKind = getIssueLinkKind(file);
|
||||
|
||||
if (isImage && file.url) {
|
||||
return (
|
||||
@@ -787,7 +795,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
);
|
||||
}
|
||||
|
||||
if (githubLinkKind && file.url) {
|
||||
if (issueLinkKind && file.url) {
|
||||
return (
|
||||
<Tooltip key={file.url || `${fileName}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -802,11 +810,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{githubLinkKind === 'pr' ? (
|
||||
<Icon name="git-pull-request" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
) : (
|
||||
<Icon name="github" className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
)}
|
||||
<Icon name={issueLinkIcon(issueLinkKind)} className={cn("text-muted-foreground", compact ? "h-3.5 w-3.5" : "h-4 w-4")} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{fileName}</p>
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MobileComposerHolders {
|
||||
draftPickerOpen: boolean;
|
||||
issuePickerOpen: boolean;
|
||||
prPickerOpen: boolean;
|
||||
linearPickerOpen: boolean;
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
@@ -204,7 +205,8 @@ export function useMobileComposerShell(
|
||||
|| holders.controlsPanelOpen
|
||||
|| holders.attachMenuOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen;
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen;
|
||||
|
||||
// Installed PWA (standalone): a focus() from a bare timeout is outside the
|
||||
// user gesture and iOS refuses to raise the keyboard for it (Safari
|
||||
@@ -212,7 +214,7 @@ export function useMobileComposerShell(
|
||||
// 'oc:mobile-overlay-closed' synchronously from the same React flush as the
|
||||
// click that closed it — refocus right there, while the gesture is live.
|
||||
const pickerDialogsOpenRef = React.useRef(false);
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen;
|
||||
pickerDialogsOpenRef.current = holders.issuePickerOpen || holders.prPickerOpen || holders.linearPickerOpen;
|
||||
const skipNextCloseRestoreRef = React.useRef(false);
|
||||
const openSheetCountRef = React.useRef(0);
|
||||
const holdFocusUntilRef = React.useRef(0);
|
||||
@@ -307,6 +309,7 @@ export function useMobileComposerShell(
|
||||
|| holders.draftPickerOpen
|
||||
|| holders.issuePickerOpen
|
||||
|| holders.prPickerOpen
|
||||
|| holders.linearPickerOpen
|
||||
|| holders.isDragging;
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
+14
@@ -40,6 +40,7 @@ const input = (overrides: Partial<OutgoingMessageInput> = {}): OutgoingMessageIn
|
||||
syntheticTexts: [],
|
||||
linkedIssue: null,
|
||||
linkedPr: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -203,6 +204,17 @@ describe('synthetic context', () => {
|
||||
.toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' });
|
||||
});
|
||||
|
||||
test('a linked Linear issue is sent as context', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'fix it',
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear body' },
|
||||
}), deps());
|
||||
expect(result.additionalParts).toHaveLength(1);
|
||||
expect(result.additionalParts[0].text).toBe('linear body');
|
||||
expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY])
|
||||
.toEqual({ kind: 'linear-issue', identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12' });
|
||||
});
|
||||
|
||||
test('synthetic texts precede the linked references', () => {
|
||||
const result = buildOutgoingMessage(input({
|
||||
composerText: 'x',
|
||||
@@ -255,6 +267,7 @@ describe('full assembly order', () => {
|
||||
syntheticTexts: ['synthetic'],
|
||||
linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' },
|
||||
linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' },
|
||||
linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' },
|
||||
}), deps());
|
||||
|
||||
expect(result.primaryText).toBe('q1');
|
||||
@@ -265,6 +278,7 @@ describe('full assembly order', () => {
|
||||
'issue',
|
||||
'pr-how',
|
||||
'pr-diff',
|
||||
'linear',
|
||||
'use: deploy',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface OutgoingMessageInput {
|
||||
syntheticTexts: readonly string[];
|
||||
linkedIssue: { number: number; title: string; url: string; contextText: string } | null;
|
||||
linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null;
|
||||
linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +162,11 @@ export function buildOutgoingMessage(
|
||||
additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context));
|
||||
}
|
||||
|
||||
if (input.linkedLinearIssue) {
|
||||
const { identifier, title, url, contextText } = input.linkedLinearIssue;
|
||||
additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText));
|
||||
}
|
||||
|
||||
const skillInstruction = deps.buildSkillInstruction(skillNames);
|
||||
if (skillInstruction) {
|
||||
additionalParts.push({ text: skillInstruction, synthetic: true });
|
||||
|
||||
@@ -26,6 +26,8 @@ type ComposerAttachmentControlsProps = {
|
||||
handlePickLocalFiles: () => void;
|
||||
openIssuePicker: () => void;
|
||||
openPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
openLinearPicker?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
onMenuOpenChange?: (open: boolean) => void;
|
||||
/** Mobile: open the attachment bottom sheet instead of the dropdown menu. */
|
||||
@@ -41,6 +43,8 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
handlePickLocalFiles,
|
||||
openIssuePicker,
|
||||
openPrPicker,
|
||||
showLinearPicker,
|
||||
openLinearPicker,
|
||||
onOpenSettings,
|
||||
} = props;
|
||||
|
||||
@@ -114,6 +118,16 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
<Icon name="git-pull-request"/>
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</DropdownMenuItem>
|
||||
{showLinearPicker && openLinearPicker ? (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(openLinearPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="linear"/>
|
||||
{t('chat.chatInput.actions.linkLinearIssue')}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -136,6 +150,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
|
||||
prev.isVSCode === next.isVSCode
|
||||
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||
&& prev.iconSizeClass === next.iconSizeClass
|
||||
&& prev.showLinearPicker === next.showLinearPicker
|
||||
&& prev.onOpenSettings === next.onOpenSettings
|
||||
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
||||
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ComposerFooterProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onToggleExpandedInput: () => void;
|
||||
onTogglePermissionAutoAccept: () => void;
|
||||
@@ -94,6 +96,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onToggleExpandedInput,
|
||||
onTogglePermissionAutoAccept,
|
||||
@@ -130,6 +134,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
@@ -199,6 +205,8 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
<FocusModeButton
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface MobilePillComposerProps {
|
||||
onPickLocalFiles: () => void;
|
||||
onOpenIssuePicker: () => void;
|
||||
onOpenPrPicker: () => void;
|
||||
showLinearPicker?: boolean;
|
||||
onOpenLinearPicker?: () => void;
|
||||
onOpenAttachSheet: () => void;
|
||||
onStartDictation: () => void;
|
||||
onAbort: () => void;
|
||||
@@ -63,6 +65,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
onPickLocalFiles,
|
||||
onOpenIssuePicker,
|
||||
onOpenPrPicker,
|
||||
showLinearPicker,
|
||||
onOpenLinearPicker,
|
||||
onOpenAttachSheet,
|
||||
onStartDictation,
|
||||
onAbort,
|
||||
@@ -95,6 +99,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) {
|
||||
handlePickLocalFiles={onPickLocalFiles}
|
||||
openIssuePicker={onOpenIssuePicker}
|
||||
openPrPicker={onOpenPrPicker}
|
||||
showLinearPicker={showLinearPicker}
|
||||
openLinearPicker={onOpenLinearPicker}
|
||||
onOpenMobileSheet={onOpenAttachSheet}
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -56,4 +56,12 @@ describe('messagePreview', () => {
|
||||
const parts = [contextPart(chatQuote('quoted bit'), 'raw model text')]
|
||||
expect(getPromptPreviewText(parts)).toBe('raw model text')
|
||||
})
|
||||
|
||||
test('labels a Linear issue attachment from its identifier and title', () => {
|
||||
const parts = [contextPart(
|
||||
{ kind: 'linear-issue', identifier: 'ENG-12', title: 'Fix login', url: 'https://linear.app/eng-12' },
|
||||
'fetched issue body',
|
||||
)]
|
||||
expect(getPromptPreviewText(parts, t)).toBe('ENG-12 Fix login')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,8 @@ const contextSummary = (payload: ContextPartPayload, t: Translate): string => {
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'github-pr':
|
||||
return `#${payload.number} ${payload.title}`;
|
||||
case 'linear-issue':
|
||||
return `${payload.identifier} ${payload.title}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,6 +80,7 @@ const contextBody = (payload: ContextPartPayload): string => {
|
||||
return payload.quote;
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -318,3 +318,41 @@ describe('CJK-aware link parsing', () => {
|
||||
expect(hrefOf(renderMarkdownSync('[a](url "title")'))).toBe('url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Escaped brackets versus display math', () => {
|
||||
// `\[...\]` is display math in LaTeX and an escaped bracket pair in
|
||||
// CommonMark. Prose escapes brackets far more often than it opens display
|
||||
// math mid-sentence, so math only wins when it owns its line.
|
||||
test('keeps escaped brackets inside a link as link text', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files](https://example.com/?session=ses_1)',
|
||||
);
|
||||
expect(html).toContain('href="https://example.com/?session=ses_1"');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('leaves escaped brackets in prose as literal brackets', () => {
|
||||
const html = renderMarkdownSync('Release \\[Bug\\] fixed in v2.');
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
// Verbatim body of a Linear status comment, which Linear itself renders as
|
||||
// one link while we used to split it into three blocks.
|
||||
test('renders a Linear comment with an escaped-bracket title as one link', () => {
|
||||
const html = renderMarkdownSync(
|
||||
'[OpenChamber session completed: OPE-316 \\[Bug\\] Opening files with template-literal'
|
||||
+ ' code triggers catastrophic backtracking → renderer OOM → black/frozen desktop app'
|
||||
+ ' (v1.17.2)](http://127.0.0.1:63418/?session=ses_fb0bb916effe26bQ1Ofr6Rv4Ei)',
|
||||
);
|
||||
expect(html.match(/<a /g)).toHaveLength(1);
|
||||
expect(html).toContain('[Bug]');
|
||||
expect(html).not.toContain('katex');
|
||||
});
|
||||
|
||||
test('still renders display math that owns its line', () => {
|
||||
expect(renderMarkdownSync('\\[x = y\\]')).toContain('katex');
|
||||
expect(renderMarkdownSync('Before\n\n\\[\nx = y\n\\]\n\nAfter')).toContain('katex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,15 +314,25 @@ const inlineMathExtension = {
|
||||
},
|
||||
};
|
||||
|
||||
// `\[` is display math in LaTeX, but it is also CommonMark's escape for a
|
||||
// literal `[`, and prose escapes brackets far more often than it opens display
|
||||
// math. Reading every `\[` as math turned text like
|
||||
// `[title \[Bug\] more](url)` into a KaTeX block that split the paragraph and
|
||||
// tore the link apart. Display math therefore has to own its line: it must
|
||||
// start one and its `\]` must end one. Anything mid-sentence stays an escape.
|
||||
const BLOCK_MATH_RE = /^[ \t]*\\\[([\s\S]+?)\\\][ \t]*(?:\n|$)/;
|
||||
const BLOCK_MATH_LINE_START_RE = /(?:^|\n)[ \t]*\\\[/;
|
||||
|
||||
const blockMathExtension = {
|
||||
name: 'blockMath',
|
||||
level: 'block' as const,
|
||||
start(src: string) {
|
||||
const index = src.indexOf('\\[');
|
||||
return index < 0 ? undefined : index;
|
||||
const match = BLOCK_MATH_LINE_START_RE.exec(src);
|
||||
// Point marked at the `\[` itself, never at the newline before it.
|
||||
return match ? match.index + match[0].length - 2 : undefined;
|
||||
},
|
||||
tokenizer(src: string): MathToken | undefined {
|
||||
const match = /^\\\[([\s\S]+?)\\\]/.exec(src);
|
||||
const match = BLOCK_MATH_RE.exec(src);
|
||||
if (!match) return undefined;
|
||||
return { type: 'blockMath', raw: match[0], text: match[1] ?? '' };
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readContextPart } from '@/lib/messages/contextParts';
|
||||
|
||||
const GITHUB_ISSUE_CONTEXT_PREFIX = 'GitHub issue context (JSON)';
|
||||
const GITHUB_PR_CONTEXT_PREFIX = 'GitHub pull request context (JSON)';
|
||||
const LINEAR_ISSUE_CONTEXT_PREFIX = 'Linear issue context (JSON)';
|
||||
|
||||
type GitHubIssueContextPayload = {
|
||||
issue?: {
|
||||
@@ -20,6 +21,14 @@ type GitHubPrContextPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
type LinearIssueContextPayload = {
|
||||
issue?: {
|
||||
identifier?: unknown;
|
||||
title?: unknown;
|
||||
url?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const isPositiveNumber = (value: unknown): value is number => {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
};
|
||||
@@ -79,6 +88,24 @@ const buildGitHubAttachmentPart = (text: string): Part | null => {
|
||||
} as Part;
|
||||
}
|
||||
|
||||
const linearPayload = parseSyntheticJsonPayload<LinearIssueContextPayload>(text, LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
if (linearPayload) {
|
||||
const issue = linearPayload.issue;
|
||||
const identifier = issue?.identifier;
|
||||
const title = issue?.title;
|
||||
const url = issue?.url;
|
||||
if (typeof identifier !== 'string' || identifier.trim().length === 0 || typeof title !== 'string' || typeof url !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${identifier}: ${title}`,
|
||||
url,
|
||||
} as Part;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -106,7 +133,8 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
const normalizedText = text.trimStart();
|
||||
return shouldKeepSyntheticUserText(text, planModeEnabled)
|
||||
|| normalizedText.startsWith(GITHUB_ISSUE_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX);
|
||||
|| normalizedText.startsWith(GITHUB_PR_CONTEXT_PREFIX)
|
||||
|| normalizedText.startsWith(LINEAR_ISSUE_CONTEXT_PREFIX);
|
||||
})
|
||||
.map((part) => {
|
||||
const rawPart = part as Record<string, unknown>;
|
||||
@@ -119,10 +147,18 @@ export const normalizeUserDisplayParts = (parts: Part[], options?: { planModeEna
|
||||
|
||||
if (synthetic) {
|
||||
const contextPayload = readContextPart(part);
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr') {
|
||||
if (contextPayload?.kind === 'github-issue' || contextPayload?.kind === 'github-pr' || contextPayload?.kind === 'linear-issue') {
|
||||
// SAFETY: same display-only file-part shape the legacy
|
||||
// buildGitHubAttachmentPart produces; consumed by
|
||||
// FileAttachment, which matches on the mime type.
|
||||
if (contextPayload.kind === 'linear-issue') {
|
||||
return {
|
||||
type: 'file',
|
||||
mime: 'application/vnd.openchamber.linear-issue-link',
|
||||
filename: `${contextPayload.identifier}: ${contextPayload.title}`,
|
||||
url: contextPayload.url,
|
||||
} as Part;
|
||||
}
|
||||
return {
|
||||
type: 'file',
|
||||
mime: contextPayload.kind === 'github-issue'
|
||||
|
||||
@@ -127,10 +127,11 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
||||
annotations, PR comments/checks): `UserContextPart.tsx`. `UserTextPart`
|
||||
routes to it when the part's metadata carries an `openchamberContext`
|
||||
payload (see `lib/messages/contextParts.ts`, which owns both the send-time
|
||||
builder and the read-back parser). Linked GitHub issues/PRs are instead
|
||||
converted to link file-parts in `normalizeUserDisplayParts.ts`. Legacy
|
||||
pre-metadata messages still render via text sniffing (`<terminal_context>`
|
||||
blocks, `GitHub issue context (JSON)` prefixes).
|
||||
builder and the read-back parser). Linked GitHub issues/PRs and Linear
|
||||
issues are instead converted to link file-parts in
|
||||
`normalizeUserDisplayParts.ts`. Legacy pre-metadata messages still render
|
||||
via text sniffing (`<terminal_context>` blocks, `GitHub issue context (JSON)`
|
||||
and `Linear issue context (JSON)` prefixes).
|
||||
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||
|
||||
@@ -185,6 +185,7 @@ const UserContextPart: React.FC<{
|
||||
);
|
||||
case 'github-issue':
|
||||
case 'github-pr':
|
||||
case 'linear-issue':
|
||||
// Rendered as link attachments by normalizeUserDisplayParts.
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -319,8 +319,10 @@ Stored in session metadata as a **snapshot** (`lib/linkedIssues.ts`, namespace
|
||||
pinned messages. Number, title, url, author and avatar only — the body,
|
||||
comments and state belong to GitHub, and mirroring them would mean owning their
|
||||
staleness. The stored title can drift; that is the price of a store that never
|
||||
needs refreshing. The row opens the real thread, which is where current state
|
||||
lives.
|
||||
needs refreshing. A GitHub row opens github.com. A Linear row opens the
|
||||
right-hand Linear panel when Linear is connected on desktop/web; otherwise it
|
||||
opens the Linear URL (no rail in VS Code or the phone shell, and none while
|
||||
disconnected).
|
||||
|
||||
Writes happen **after** the send promise resolves and are deliberately
|
||||
swallowed on failure: the message went out, and a missing bookkeeping entry
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon } from '@/components/icon/Icon';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import { getLinkedIssues } from '@/lib/linkedIssues';
|
||||
import { getLinkedIssues, canOpenLinearIssueInContextPanel } from '@/lib/linkedIssues';
|
||||
import { fetchSessionKnowledgeSummary, setSessionProjectContextPin, type SessionKnowledgeSummary } from '@/lib/sessionKnowledgeApi';
|
||||
import { useProjectContextStore } from '@/stores/useProjectContextStore';
|
||||
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
|
||||
@@ -12,6 +12,10 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import { resolveProjectContextId } from '@/lib/projectContextApi';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives';
|
||||
import { useReportWorkStatusPresence } from './presenceContext';
|
||||
import { resolveDraftPinnedKnowledge } from './draftKnowledge';
|
||||
@@ -32,6 +36,11 @@ type Props = {
|
||||
*/
|
||||
export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory }) => {
|
||||
const { t } = useI18n();
|
||||
const { linear } = useRuntimeAPIs();
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const mobileActions = useMobileAppActions();
|
||||
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||
const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus);
|
||||
|
||||
const session = useSession(sessionId ?? '', directory ?? undefined);
|
||||
const newSessionDraft = useSessionUIStore((state) => state.newSessionDraft);
|
||||
@@ -138,6 +147,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
const pinnedCount = visibleKnowledge.notes.length + visibleKnowledge.plans.length;
|
||||
|
||||
const linked = React.useMemo(() => getLinkedIssues(session), [session]);
|
||||
const openLinkedIssue = React.useCallback((entry: (typeof linked)[number]) => {
|
||||
if (
|
||||
entry.kind === 'linear'
|
||||
&& directory
|
||||
&& canOpenLinearIssueInContextPanel({
|
||||
linearAvailable: Boolean(linear),
|
||||
linearConnected,
|
||||
inDedicatedMobileShell: mobileActions != null,
|
||||
directory,
|
||||
})
|
||||
) {
|
||||
setLinearIssueFocus(entry.identifier);
|
||||
openContextPanelTab(directory, { mode: 'linear' });
|
||||
return;
|
||||
}
|
||||
window.open(entry.url, '_blank', 'noopener,noreferrer');
|
||||
}, [directory, linear, linearConnected, mobileActions, openContextPanelTab, setLinearIssueFocus]);
|
||||
// Connected servers only. A disabled server contributes nothing to the
|
||||
// context, so counting it here contradicts the MCP section right above,
|
||||
// which shows the same servers switched off.
|
||||
@@ -158,8 +184,8 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
// The heading names what is distinctive about this session when there is
|
||||
// something — an attached thread — and falls back to the ambient counts
|
||||
// when there is not. `1 · 33 · 2` said nothing without opening the section.
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue').length;
|
||||
const prCount = linked.length - issueCount;
|
||||
const issueCount = linked.filter((entry) => entry.kind === 'issue' || entry.kind === 'linear').length;
|
||||
const prCount = linked.filter((entry) => entry.kind === 'pull').length;
|
||||
const summaryParts: string[] = [];
|
||||
if (issueCount > 0) {
|
||||
summaryParts.push(issueCount === 1
|
||||
@@ -206,17 +232,23 @@ export const WorkStatusContextSection: React.FC<Props> = ({ sessionId, directory
|
||||
<img src={entry.authorAvatarUrl} alt="" className="size-4 shrink-0 rounded-full" loading="lazy" />
|
||||
) : (
|
||||
<Icon
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : 'error-warning'}
|
||||
name={entry.kind === 'pull' ? 'git-pull-request' : entry.kind === 'linear' ? 'linear' : 'error-warning'}
|
||||
className="size-4 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
label={entry.title}
|
||||
muted
|
||||
// The stored snapshot is enough to render; the live thread only ever
|
||||
// exists on github.com.
|
||||
onClick={() => window.open(entry.url, '_blank', 'noopener,noreferrer')}
|
||||
ariaLabel={t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={<WorkStatusValue tone="muted">{`#${entry.number}`}</WorkStatusValue>}
|
||||
// GitHub threads still live on github.com. A Linear issue opens in
|
||||
// the right-hand panel when that rail exists; otherwise the Linear URL.
|
||||
onClick={() => openLinkedIssue(entry)}
|
||||
ariaLabel={entry.kind === 'linear'
|
||||
? t('chat.workStatus.linkedIssues.openLinear', { identifier: entry.identifier })
|
||||
: t('chat.workStatus.linkedIssues.open', { number: entry.number })}
|
||||
value={(
|
||||
<WorkStatusValue tone="muted">
|
||||
{entry.kind === 'linear' ? entry.identifier : `#${entry.number}`}
|
||||
</WorkStatusValue>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ export const iconSpriteData = {
|
||||
"target": `<path d="M12 1.99999C12.5523 1.99999 13 2.4477 13 2.99999C12.9999 3.55224 12.5522 3.99999 12 3.99999C7.58172 3.99999 4 7.58171 4 12C4.00004 16.4182 7.58174 20 12 20C16.4182 20 19.9999 16.4182 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C21.9999 17.5228 17.5228 22 12 22C6.47717 22 2.00004 17.5228 2 12C2 6.47714 6.47715 1.99999 12 1.99999ZM12 5.99999C12.5523 5.99999 13 6.4477 13 6.99999C12.9999 7.55224 12.5522 7.99999 12 7.99999C9.79085 7.99999 7.99999 9.79085 7.99999 12C8.00004 14.2091 9.79088 16 12 16C14.2091 16 15.9999 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C17.9999 15.3137 15.3137 18 12 18C8.68631 18 6.00004 15.3137 6 12C6 8.68628 8.68629 5.99999 12 5.99999ZM17.6562 2.10057C18.0468 1.71005 18.6807 1.71005 19.0713 2.10057C19.4614 2.49105 19.4615 3.12419 19.0713 3.51463L18.3633 4.22069L18.3642 4.22167C17.9737 4.61219 17.9737 5.2452 18.3642 5.63573C18.7548 6.02612 19.3878 6.02621 19.7783 5.63573L20.4853 4.9287C20.8759 4.53839 21.5089 4.53826 21.8994 4.9287C22.2899 5.31915 22.2897 5.95222 21.8994 6.34276L19.7783 8.46483C19.5909 8.65223 19.3363 8.75671 19.0713 8.75682H16.6572L12.707 12.707C12.3165 13.0974 11.6834 13.0974 11.293 12.707C10.9025 12.3165 10.9026 11.6835 11.293 11.293L15.2422 7.34374V4.9287C15.2422 4.66356 15.3477 4.40916 15.5351 4.22167L17.6562 2.10057Z" fill="currentColor"/>`,
|
||||
"target-fill": `<path d="M12 2C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2ZM12 6C12.5523 6 13 6.44772 13 7C13 7.55228 12.5523 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 11.4477 16.4477 11 17 11C17.5523 11 18 11.4477 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 8.68629 8.68629 6 12 6ZM18.5713 2.10059C18.8474 2.1006 19.0712 2.32449 19.0713 2.60059V4.42969C19.0716 4.70553 19.2954 4.92866 19.5713 4.92871H21.3994C21.6754 4.92871 21.8992 5.15275 21.8994 5.42871V6.34375L20.0107 8.23242C19.6358 8.60719 19.1268 8.81824 18.5967 8.81836H16.5967L12.707 12.707C12.3165 13.0974 11.6835 13.0975 11.293 12.707C10.9027 12.3165 10.9026 11.6834 11.293 11.293L15.1826 7.4043V5.4043C15.1826 4.87411 15.3928 4.36526 15.7676 3.99023L17.6572 2.10059H18.5713Z" fill="currentColor"/>`,
|
||||
"task": `<path d="M19 4H5V20H19V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H19.9997C20.5519 2 20.9996 2.44772 20.9997 3L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM11.2929 13.1213L15.5355 8.87868L16.9497 10.2929L11.2929 15.9497L7.40381 12.0607L8.81802 10.6464L11.2929 13.1213Z" fill="currentColor"/>`,
|
||||
"team": `<path d="M12 11C14.7614 11 17 13.2386 17 16V22H15V16C15 14.4023 13.7511 13.0963 12.1763 13.0051L12 13C10.4023 13 9.09634 14.2489 9.00509 15.8237L9 16V22H7V16C7 13.2386 9.23858 11 12 11ZM5.5 14C5.77885 14 6.05009 14.0326 6.3101 14.0942C6.14202 14.594 6.03873 15.122 6.00896 15.6693L6 16L6.0007 16.0856C5.88757 16.0456 5.76821 16.0187 5.64446 16.0069L5.5 16C4.7203 16 4.07955 16.5949 4.00687 17.3555L4 17.5V22H2V17.5C2 15.567 3.567 14 5.5 14ZM18.5 14C20.433 14 22 15.567 22 17.5V22H20V17.5C20 16.7203 19.4051 16.0796 18.6445 16.0069L18.5 16C18.3248 16 18.1566 16.03 18.0003 16.0852L18 16C18 15.3343 17.8916 14.694 17.6915 14.0956C17.9499 14.0326 18.2211 14 18.5 14ZM5.5 8C6.88071 8 8 9.11929 8 10.5C8 11.8807 6.88071 13 5.5 13C4.11929 13 3 11.8807 3 10.5C3 9.11929 4.11929 8 5.5 8ZM18.5 8C19.8807 8 21 9.11929 21 10.5C21 11.8807 19.8807 13 18.5 13C17.1193 13 16 11.8807 16 10.5C16 9.11929 17.1193 8 18.5 8ZM5.5 10C5.22386 10 5 10.2239 5 10.5C5 10.7761 5.22386 11 5.5 11C5.77614 11 6 10.7761 6 10.5C6 10.2239 5.77614 10 5.5 10ZM18.5 10C18.2239 10 18 10.2239 18 10.5C18 10.7761 18.2239 11 18.5 11C18.7761 11 19 10.7761 19 10.5C19 10.2239 18.7761 10 18.5 10ZM12 2C14.2091 2 16 3.79086 16 6C16 8.20914 14.2091 10 12 10C9.79086 10 8 8.20914 8 6C8 3.79086 9.79086 2 12 2ZM12 4C10.8954 4 10 4.89543 10 6C10 7.10457 10.8954 8 12 8C13.1046 8 14 7.10457 14 6C14 4.89543 13.1046 4 12 4Z" fill="currentColor"/>`,
|
||||
"terminal": `<path d="M10.9999 12L3.92886 19.0711L2.51465 17.6569L8.1715 12L2.51465 6.34317L3.92886 4.92896L10.9999 12ZM10.9999 19H20.9999V21H10.9999V19Z" fill="currentColor"/>`,
|
||||
"terminal-box": `<path d="M3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM4 5V19H20V5H4ZM12 15H18V17H12V15ZM8.66685 12L5.83842 9.17157L7.25264 7.75736L11.4953 12L7.25264 16.2426L5.83842 14.8284L8.66685 12Z" fill="currentColor"/>`,
|
||||
"terminal-window": `<path d="M20 9V5H4V9H20ZM20 11H4V19H20V11ZM3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3ZM5 12H8V17H5V12ZM5 6H7V8H5V6ZM9 6H11V8H9V6Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -16,6 +16,9 @@ const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/w
|
||||
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
|
||||
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
|
||||
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
|
||||
// The Linear rail icon stays hidden until a workspace is connected, so most
|
||||
// users never render this panel; keep it out of the main bundle.
|
||||
const LinearIssuesView = lazyWithChunkRecovery(() => import('@/components/views/LinearIssuesView').then((m) => ({ default: m.LinearIssuesView })));
|
||||
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
|
||||
import { ProjectContextPanel } from './RightSidebarTabs';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -119,6 +122,7 @@ const getModeLabel = (
|
||||
if (mode === 'browser') return t('contextPanel.mode.browser');
|
||||
if (mode === 'git') return t('layout.rightSidebar.git');
|
||||
if (mode === 'pr') return t('contextPanel.mode.pr');
|
||||
if (mode === 'linear') return t('contextPanel.mode.linear');
|
||||
if (mode === 'notes') return t('contextRail.surface.notes');
|
||||
if (mode === 'terminal') return t('layout.mainTab.terminal');
|
||||
return t('contextPanel.mode.context');
|
||||
@@ -213,6 +217,10 @@ const getTabIcon = (
|
||||
return <Icon name="github" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'linear') {
|
||||
return <Icon name="linear" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'notes') {
|
||||
return <Icon name="sticky-note" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
@@ -940,6 +948,8 @@ export const ContextPanel: React.FC = () => {
|
||||
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
||||
: activeTab?.mode === 'pr'
|
||||
? <PullRequestView />
|
||||
: activeTab?.mode === 'linear'
|
||||
? <React.Suspense fallback={null}><LinearIssuesView /></React.Suspense>
|
||||
: activeTab?.mode === 'notes'
|
||||
? <ProjectContextPanel />
|
||||
: activeTab?.mode === 'plan'
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
|
||||
|
||||
@@ -165,8 +166,11 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces);
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const closeContextPanel = useUIStore((state) => state.closeContextPanel);
|
||||
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
@@ -263,8 +267,16 @@ export const ContextPanelRail: React.FC = () => {
|
||||
isVSCode: isVSCodeRuntime(),
|
||||
screenWidth,
|
||||
tabs,
|
||||
linearConnected,
|
||||
});
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, linearConnected, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== 'linear') {
|
||||
return;
|
||||
}
|
||||
closeContextPanel(directoryKey);
|
||||
}, [activeMode, closeContextPanel, directoryKey, linearAuthChecked, linearConnected]);
|
||||
|
||||
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Guards from the OPE-296 review: stale Linear list pages must not land, and a
|
||||
* persisted Linear tab must survive reload until auth has actually resolved.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const railSource = readFileSync(join(__dirname, '..', 'ContextPanelRail.tsx'), 'utf-8');
|
||||
const issuesViewSource = readFileSync(join(__dirname, '..', '..', 'views', 'LinearIssuesView.tsx'), 'utf-8');
|
||||
const pickerSource = readFileSync(join(__dirname, '..', '..', 'session', 'LinearIssuePickerDialog.tsx'), 'utf-8');
|
||||
|
||||
const sliceFn = (source: string, marker: string, length: number) => {
|
||||
const start = source.indexOf(marker);
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
return source.slice(start, start + length);
|
||||
};
|
||||
|
||||
describe('Linear panel review guards', () => {
|
||||
test('disconnect-close waits for Linear auth to resolve', () => {
|
||||
const effect = sliceFn(railSource, 'if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== \'linear\')', 240);
|
||||
expect(effect).toContain('closeContextPanel(directoryKey)');
|
||||
expect(railSource).toContain('state.hasChecked');
|
||||
});
|
||||
|
||||
test('rail loadMore shares listRequestId with refresh', () => {
|
||||
const loadMore = sliceFn(issuesViewSource, 'const loadMore = React.useCallback(async () => {', 900);
|
||||
expect(loadMore).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
|
||||
});
|
||||
|
||||
test('picker refresh and loadMore reject stale pages', () => {
|
||||
const refresh = sliceFn(pickerSource, 'const refresh = React.useCallback(async (search = \'\') => {', 1400);
|
||||
const loadMore = sliceFn(pickerSource, 'const loadMore = React.useCallback(async () => {', 900);
|
||||
expect(refresh).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(refresh).toContain('if (requestId !== listRequestId.current) return');
|
||||
expect(loadMore).toContain('const requestId = listRequestId.current + 1');
|
||||
expect(loadMore).toContain('if (requestId !== listRequestId.current) return');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { LinearSettings } from './LinearSettings';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
@@ -15,25 +15,17 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={t('settings.page.integrations.title')}
|
||||
description={(
|
||||
<div className="space-y-3">
|
||||
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
|
||||
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.integrations.experimentalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
showSaveStatus={false}
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus
|
||||
>
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={false}
|
||||
divider={hasLinear}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
SettingsControlGroup,
|
||||
SettingsFieldRow,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||
SETTINGS_SELECT_SIZE,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import type { LinearAPI, LinearMappingResult } from '@/lib/api/types';
|
||||
|
||||
const NONE = '__none__';
|
||||
const INHERIT = '__inherit__';
|
||||
|
||||
export function LinearProjectMapping({
|
||||
linear,
|
||||
connected,
|
||||
organizationId,
|
||||
}: {
|
||||
linear: LinearAPI;
|
||||
connected: boolean;
|
||||
organizationId?: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [mapping, setMapping] = React.useState<LinearMappingResult | null>(null);
|
||||
const [loadFailed, setLoadFailed] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
const loadMapping = React.useCallback(async () => {
|
||||
if (!connected) {
|
||||
setMapping(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await linear.mappingGet();
|
||||
if (next.connected === false) {
|
||||
setMapping(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
setMapping(next);
|
||||
setLoadFailed(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Linear mapping:', error);
|
||||
setLoadFailed(true);
|
||||
}
|
||||
}, [connected, linear, organizationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadMapping();
|
||||
}, [loadMapping]);
|
||||
|
||||
const saveMapping = React.useCallback(async (next: LinearMappingResult) => {
|
||||
const teamProjectPaths: { [teamId: string]: string } = {};
|
||||
for (const team of next.teams ?? []) {
|
||||
if (team.projectPath) {
|
||||
teamProjectPaths[team.id] = team.projectPath;
|
||||
}
|
||||
}
|
||||
setIsSaving(true);
|
||||
reportSettingsSaveState('saving');
|
||||
try {
|
||||
const saved = await linear.mappingSet({
|
||||
defaultProjectPath: next.defaultProjectPath ?? null,
|
||||
teamProjectPaths,
|
||||
});
|
||||
if (saved.connected === false) {
|
||||
setMapping(null);
|
||||
reportSettingsSaveState('error');
|
||||
return;
|
||||
}
|
||||
setMapping(saved);
|
||||
setLoadFailed(false);
|
||||
reportSettingsSaveState('saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to save Linear mapping:', error);
|
||||
reportSettingsSaveState('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [linear]);
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loadFailed && !mapping) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.loadFailed')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectLabel = (path: string) => {
|
||||
const project = projects.find((entry) => entry.path === path);
|
||||
return project?.label?.trim() || path;
|
||||
};
|
||||
|
||||
const defaultProjectLabel = (value: string | undefined) => {
|
||||
if (!value || value === NONE) {
|
||||
return t('settings.integrations.linear.mapping.defaultProject.placeholder');
|
||||
}
|
||||
return projectLabel(value);
|
||||
};
|
||||
|
||||
const teamProjectLabel = (value: string | undefined) => {
|
||||
if (!value || value === INHERIT) {
|
||||
return t('settings.integrations.linear.mapping.teams.useDefault');
|
||||
}
|
||||
return projectLabel(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.emptyProjects')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<SettingsFieldRow
|
||||
label={t('settings.integrations.linear.mapping.defaultProject')}
|
||||
info={t('settings.integrations.linear.mapping.defaultProject.info')}
|
||||
settingsItem="integrations.linear.mapping"
|
||||
>
|
||||
<Select
|
||||
value={mapping.defaultProjectPath || NONE}
|
||||
disabled={isSaving || projects.length === 0}
|
||||
onValueChange={(value) => {
|
||||
void saveMapping({
|
||||
...mapping,
|
||||
defaultProjectPath: value === NONE ? null : value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.integrations.linear.mapping.defaultProject.aria')}
|
||||
>
|
||||
<SelectValue placeholder={t('settings.integrations.linear.mapping.defaultProject.placeholder')}>
|
||||
{defaultProjectLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>
|
||||
{t('settings.integrations.linear.mapping.defaultProject.placeholder')}
|
||||
</SelectItem>
|
||||
{mapping.defaultProjectPath && !projects.some((entry) => entry.path === mapping.defaultProjectPath) ? (
|
||||
<SelectItem value={mapping.defaultProjectPath}>{mapping.defaultProjectPath}</SelectItem>
|
||||
) : null}
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.path}>
|
||||
{projectLabel(project.path)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
|
||||
<SettingsControlGroup
|
||||
title={t('settings.integrations.linear.mapping.teams')}
|
||||
info={t('settings.integrations.linear.mapping.teams.info')}
|
||||
>
|
||||
{(mapping.teams ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.mapping.emptyTeams')}
|
||||
</p>
|
||||
) : (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
{(mapping.teams ?? []).map((team) => (
|
||||
<SettingsFieldRow
|
||||
key={team.id}
|
||||
label={`${team.key} · ${team.name}`}
|
||||
>
|
||||
<Select
|
||||
value={team.projectPath || INHERIT}
|
||||
disabled={isSaving || projects.length === 0}
|
||||
onValueChange={(value) => {
|
||||
void saveMapping({
|
||||
...mapping,
|
||||
teams: (mapping.teams ?? []).map((entry) => (
|
||||
entry.id === team.id
|
||||
? { ...entry, projectPath: value === INHERIT ? null : value }
|
||||
: entry
|
||||
)),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size={SETTINGS_SELECT_SIZE}
|
||||
className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}
|
||||
aria-label={t('settings.integrations.linear.mapping.teams.aria', { team: team.key })}
|
||||
>
|
||||
<SelectValue placeholder={t('settings.integrations.linear.mapping.teams.useDefault')}>
|
||||
{teamProjectLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={INHERIT}>
|
||||
{t('settings.integrations.linear.mapping.teams.useDefault')}
|
||||
</SelectItem>
|
||||
{team.projectPath && !projects.some((entry) => entry.path === team.projectPath) ? (
|
||||
<SelectItem value={team.projectPath}>{team.projectPath}</SelectItem>
|
||||
) : null}
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.path}>
|
||||
{projectLabel(project.path)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsFieldRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsControlGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
SettingsFieldRow,
|
||||
SETTINGS_FIELDS_STACK_CLASS,
|
||||
} from '@/components/sections/shared/SettingsSection';
|
||||
import { reportSettingsSaveState } from '@/lib/persistence';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { LinearAPI } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Status comments are written into a Linear workspace other people read, so
|
||||
* they stay off until the user turns them on. The server posts nothing while
|
||||
* this is off, including the completed and failure comments the event hub
|
||||
* sends without going through this interface.
|
||||
*/
|
||||
export function LinearSessionComments({
|
||||
linear,
|
||||
connected,
|
||||
}: {
|
||||
linear: LinearAPI;
|
||||
connected: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = React.useState<boolean | null>(null);
|
||||
const [loadFailed, setLoadFailed] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connected) {
|
||||
setEnabled(null);
|
||||
setLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void linear.preferencesGet()
|
||||
.then((preferences) => {
|
||||
if (cancelled) return;
|
||||
setEnabled(preferences.sessionComments);
|
||||
setLoadFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLoadFailed(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connected, linear]);
|
||||
|
||||
const save = React.useCallback(async (next: boolean) => {
|
||||
const previous = enabled;
|
||||
setEnabled(next);
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = await linear.preferencesSet({ sessionComments: next });
|
||||
setEnabled(saved.sessionComments);
|
||||
reportSettingsSaveState('saved');
|
||||
} catch {
|
||||
setEnabled(previous);
|
||||
reportSettingsSaveState('error');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [enabled, linear]);
|
||||
|
||||
if (!connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loadFailed) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.sessionComments.loadFailed')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={SETTINGS_FIELDS_STACK_CLASS}>
|
||||
<SettingsFieldRow
|
||||
label={t('settings.integrations.linear.sessionComments.label')}
|
||||
info={t('settings.integrations.linear.sessionComments.info')}
|
||||
settingsItem="integrations.linear.session-comments"
|
||||
>
|
||||
<Switch
|
||||
checked={enabled === true}
|
||||
disabled={enabled === null || isSaving}
|
||||
onCheckedChange={(checked) => { void save(checked); }}
|
||||
aria-label={t('settings.integrations.linear.sessionComments.aria')}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { LinearProjectMapping } from './LinearProjectMapping';
|
||||
import { LinearSessionComments } from './LinearSessionComments';
|
||||
|
||||
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
|
||||
const AUTHORIZATION_POLL_MS = 1_500;
|
||||
|
||||
type WorkspaceSnapshot = {
|
||||
connected: boolean;
|
||||
ids: string;
|
||||
currentId: string;
|
||||
currentAuthorizedAt: number;
|
||||
};
|
||||
|
||||
function snapshotWorkspaces(status: {
|
||||
connected?: boolean;
|
||||
organization?: { id?: string } | null;
|
||||
workspaces?: Array<{ id: string; current: boolean; authorizedAt?: number | null }>;
|
||||
} | null): WorkspaceSnapshot {
|
||||
const workspaces = status?.workspaces ?? [];
|
||||
const current = workspaces.find((entry) => entry.current);
|
||||
return {
|
||||
connected: Boolean(status?.connected),
|
||||
ids: workspaces.map((entry) => entry.id).slice().sort().join(','),
|
||||
currentId: current?.id || status?.organization?.id || '',
|
||||
currentAuthorizedAt: current?.authorizedAt ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function authorizationCompleted(previous: WorkspaceSnapshot, next: WorkspaceSnapshot): boolean {
|
||||
if (!next.connected) return false;
|
||||
if (!previous.connected) return true;
|
||||
return next.ids !== previous.ids
|
||||
|| next.currentId !== previous.currentId
|
||||
|| next.currentAuthorizedAt !== previous.currentAuthorizedAt;
|
||||
}
|
||||
|
||||
export const LinearSettings: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const runtimeLinear = getRegisteredRuntimeAPIs()?.linear;
|
||||
const status = useLinearAuthStore((state) => state.status);
|
||||
const isLoading = useLinearAuthStore((state) => state.isLoading);
|
||||
const hasChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const refreshStatus = useLinearAuthStore((state) => state.refreshStatus);
|
||||
const setStatus = useLinearAuthStore((state) => state.setStatus);
|
||||
|
||||
const [isBusy, setIsBusy] = React.useState(false);
|
||||
const [isWaiting, setIsWaiting] = React.useState(false);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const pollTimerRef = React.useRef<number | null>(null);
|
||||
|
||||
const stopWaiting = React.useCallback(() => {
|
||||
if (pollTimerRef.current != null) {
|
||||
window.clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
setIsWaiting(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!runtimeLinear) {
|
||||
return;
|
||||
}
|
||||
if (!hasChecked) {
|
||||
void refreshStatus(runtimeLinear);
|
||||
}
|
||||
return () => {
|
||||
stopWaiting();
|
||||
};
|
||||
}, [hasChecked, refreshStatus, runtimeLinear, stopWaiting]);
|
||||
|
||||
const startConnect = React.useCallback(async () => {
|
||||
if (!runtimeLinear) return;
|
||||
stopWaiting();
|
||||
setIsBusy(true);
|
||||
const previous = snapshotWorkspaces(useLinearAuthStore.getState().status);
|
||||
try {
|
||||
const payload = await runtimeLinear.authStart(isDesktopShell() ? 'desktop' : 'web');
|
||||
setIsWaiting(true);
|
||||
setOpen(true);
|
||||
void openExternalUrl(payload.authorizationUrl);
|
||||
|
||||
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
|
||||
pollTimerRef.current = window.setInterval(() => {
|
||||
void (async () => {
|
||||
if (Date.now() > deadline) {
|
||||
stopWaiting();
|
||||
toast.error(t('settings.integrations.linear.toast.authorizationFailed'));
|
||||
return;
|
||||
}
|
||||
const next = await refreshStatus(runtimeLinear, { force: true });
|
||||
if (authorizationCompleted(previous, snapshotWorkspaces(next))) {
|
||||
stopWaiting();
|
||||
toast.success(t('settings.integrations.linear.toast.connected'));
|
||||
void focusDesktopWindow();
|
||||
}
|
||||
})();
|
||||
}, AUTHORIZATION_POLL_MS);
|
||||
} catch (error) {
|
||||
console.error('Failed to start Linear connect:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.startConnectFailed'));
|
||||
stopWaiting();
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
|
||||
|
||||
const activateWorkspace = React.useCallback(async (organizationId: string) => {
|
||||
if (!runtimeLinear || !organizationId) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
const payload = await runtimeLinear.authActivate(organizationId);
|
||||
setStatus(payload);
|
||||
toast.success(t('settings.integrations.linear.toast.workspaceSwitched'));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch Linear workspace:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.workspaceSwitchFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [runtimeLinear, setStatus, t]);
|
||||
|
||||
const disconnect = React.useCallback(async () => {
|
||||
if (!runtimeLinear) return;
|
||||
setIsBusy(true);
|
||||
try {
|
||||
stopWaiting();
|
||||
await runtimeLinear.authDisconnect();
|
||||
toast.success(t('settings.integrations.linear.toast.disconnected'));
|
||||
await refreshStatus(runtimeLinear, { force: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect Linear:', error);
|
||||
toast.error(t('settings.integrations.linear.toast.disconnectFailed'));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refreshStatus, runtimeLinear, stopWaiting, t]);
|
||||
|
||||
if (!runtimeLinear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connected = Boolean(status?.connected);
|
||||
const user = status?.user;
|
||||
const organization = status?.organization;
|
||||
const workspaces = status?.workspaces ?? [];
|
||||
const otherWorkspaces = workspaces.filter((workspace) => !workspace.current);
|
||||
const displayName = user?.displayName?.trim() || user?.name?.trim() || t('settings.integrations.linear.label.unknownUser');
|
||||
const statusLabel = isWaiting
|
||||
? t('settings.integrations.linear.status.waiting')
|
||||
: isLoading && !hasChecked
|
||||
? t('common.loading')
|
||||
: connected
|
||||
? (organization?.name?.trim() || t('settings.integrations.linear.status.connected'))
|
||||
: t('settings.integrations.linear.status.notConnected');
|
||||
const statusClassName = isWaiting
|
||||
? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]'
|
||||
: connected
|
||||
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
|
||||
: 'bg-[var(--surface-muted)] text-muted-foreground';
|
||||
const expanded = isWaiting || open;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<Collapsible
|
||||
open={expanded}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isWaiting) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-settings-item="integrations.linear"
|
||||
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
|
||||
<Icon name="linear" className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{t('settings.integrations.linear.title')}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
|
||||
{t('settings.integrations.linear.description')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
statusClassName,
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
{connected ? (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={t('settings.integrations.linear.avatarAlt.withName', { name: displayName })}
|
||||
className="size-10 shrink-0 rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)] object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--interactive-border)] bg-[var(--surface-muted)]">
|
||||
<Icon name="linear" className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{displayName}</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{[organization?.name, user?.email].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isWaiting ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.integrations.linear.flow.description')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{connected ? (
|
||||
<>
|
||||
<LinearProjectMapping
|
||||
linear={runtimeLinear}
|
||||
connected={connected}
|
||||
organizationId={organization?.id ?? null}
|
||||
/>
|
||||
<LinearSessionComments linear={runtimeLinear} connected={connected} />
|
||||
{otherWorkspaces.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.integrations.linear.label.otherWorkspaces')}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{otherWorkspaces.map((workspace) => {
|
||||
const workspaceUser = workspace.user;
|
||||
const workspaceName = workspace.name?.trim()
|
||||
|| t('settings.integrations.linear.status.connected');
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-[var(--surface-subtle)] bg-[var(--surface-muted)] px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{workspaceName}</div>
|
||||
{workspaceUser?.email ? (
|
||||
<p className="truncate text-xs text-muted-foreground">{workspaceUser.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void activateWorkspace(workspace.id)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{t('settings.integrations.linear.actions.switchTo')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void startConnect()}
|
||||
disabled={isBusy || isWaiting}
|
||||
data-settings-item="integrations.linear.add-workspace"
|
||||
>
|
||||
{t('settings.integrations.linear.actions.addWorkspace')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void disconnect()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{t('settings.integrations.linear.actions.disconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : isWaiting ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="typography-micro text-muted-foreground animate-pulse">
|
||||
{t('settings.integrations.linear.flow.waiting')}
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={isBusy} onClick={stopWaiting}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => void startConnect()}
|
||||
disabled={isBusy || (isLoading && !hasChecked)}
|
||||
>
|
||||
{isBusy ? <Icon name="loader-4" className="size-3.5 animate-spin" /> : null}
|
||||
{t('settings.integrations.linear.actions.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -414,6 +414,12 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
|
||||
settingsItem="integrations.third-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.integrations.experimentalWarning')}
|
||||
</p>
|
||||
</div>
|
||||
{THIRD_PARTY_PLUGINS.map(renderPlugin)}
|
||||
</SettingsSection>
|
||||
|
||||
|
||||
@@ -61,6 +61,14 @@ const PROMPT_PAGE_MAP: Record<string, PromptPageConfig> = {
|
||||
{ id: 'github.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'linear.issue.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.linearIssueReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.linearIssueReview.description',
|
||||
blocks: [
|
||||
{ id: 'linear.issue.review.visible', titleKey: 'settings.magicPrompts.page.block.visiblePrompt' },
|
||||
{ id: 'linear.issue.review.instructions', titleKey: 'settings.magicPrompts.page.block.instructions' },
|
||||
],
|
||||
},
|
||||
'github.pr.checks.review': {
|
||||
titleKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title',
|
||||
descriptionKey: 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description',
|
||||
|
||||
@@ -35,6 +35,12 @@ export const MagicPromptsSidebar: React.FC<MagicPromptsSidebarProps> = ({ onItem
|
||||
{ id: 'github.pr.comment.single', titleKey: 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.linear',
|
||||
items: [
|
||||
{ id: 'linear.issue.review', titleKey: 'settings.magicPrompts.sidebar.item.linearIssueReview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: 'settings.magicPrompts.sidebar.group.planning',
|
||||
items: [
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
: <Icon name={iconName!} className="h-[18px] w-[18px] shrink-0 sm:h-4 sm:w-4" />}
|
||||
<span className="flex items-center gap-1.5 whitespace-nowrap overflow-hidden transition-opacity duration-150 opacity-100">
|
||||
<span className="typography-ui-label font-normal truncate">{getPageTitle(page.slug)}</span>
|
||||
{(page.slug === 'tunnel' || page.slug === 'integrations') && (
|
||||
{page.slug === 'tunnel' && (
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">
|
||||
{t('settings.view.badge.beta')}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user