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:
Alex Kutas
2026-08-30 02:18:40 +03:00
committed by GitHub
parent 1fdb78dbbe
commit 49f0a9e62f
118 changed files with 11888 additions and 143 deletions
+79 -2
View File
@@ -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(() => {
@@ -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>
)}
/>
))}