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
+1 -1
View File
@@ -38,7 +38,7 @@ Examples:
- `useFeatureFlagsStore.ts`
- `useUpdateStore.ts`
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection.
These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted.
Context-panel session chats mount only the active chat iframe. After installing
its message listener, the iframe requests its authoritative visibility from the
@@ -0,0 +1,67 @@
import { create } from 'zustand';
import type { LinearAuthStatus, RuntimeAPIs } from '@/lib/api/types';
type LinearAuthStatusWithError = LinearAuthStatus & { error?: string };
type LinearAuthStore = {
status: LinearAuthStatusWithError | null;
isLoading: boolean;
hasChecked: boolean;
setStatus: (status: LinearAuthStatusWithError | null) => void;
refreshStatus: (
runtimeLinear?: RuntimeAPIs['linear'],
options?: { force?: boolean }
) => Promise<LinearAuthStatusWithError | null>;
};
const fetchStatus = async (
runtimeLinear?: RuntimeAPIs['linear']
): Promise<LinearAuthStatusWithError> => {
if (!runtimeLinear) {
return { connected: false };
}
return runtimeLinear.authStatus();
};
let inFlightAuthRefresh: Promise<LinearAuthStatusWithError | null> | null = null;
export const useLinearAuthStore = create<LinearAuthStore>((set, get) => ({
status: null,
isLoading: false,
hasChecked: false,
setStatus: (status) => set({ status, hasChecked: true }),
refreshStatus: async (runtimeLinear, options) => {
if (!runtimeLinear) {
return get().status;
}
const { hasChecked, status } = get();
if (hasChecked && !options?.force) {
return status;
}
if (inFlightAuthRefresh) return inFlightAuthRefresh;
set({ isLoading: true });
inFlightAuthRefresh = (async () => {
try {
const payload = await fetchStatus(runtimeLinear);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A failed request is not an authoritative disconnect. Keep the last
// known status and leave `hasChecked` false so the next caller retries
// instead of hiding Linear for the rest of the session.
set((state) => ({
status: state.status
? { ...state.status, error: message }
: { connected: false, error: message },
isLoading: false,
}));
return null;
}
})().finally(() => { inFlightAuthRefresh = null; });
return inFlightAuthRefresh;
},
}));
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore';
describe('linear issue list filters', () => {
beforeEach(() => {
useUIStore.setState({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
linearIssueFocus: null,
});
});
test('stores status, assignee, team, and priority across setter calls', () => {
useUIStore.getState().setLinearIssueListStatus('todo');
expect(useUIStore.getState().linearIssueListStatus).toBe('todo');
useUIStore.getState().setLinearIssueListStatus('started');
expect(useUIStore.getState().linearIssueListStatus).toBe('started');
useUIStore.getState().setLinearIssueListStatus('inReview');
expect(useUIStore.getState().linearIssueListStatus).toBe('inReview');
useUIStore.getState().setLinearIssueListStatus('completed');
expect(useUIStore.getState().linearIssueListStatus).toBe('completed');
useUIStore.getState().setLinearIssueListStatus('canceled');
expect(useUIStore.getState().linearIssueListStatus).toBe('canceled');
useUIStore.getState().setLinearIssueListStatus('duplicate');
expect(useUIStore.getState().linearIssueListStatus).toBe('duplicate');
useUIStore.getState().setLinearIssueListStatus('backlog');
expect(useUIStore.getState().linearIssueListStatus).toBe('backlog');
useUIStore.getState().setLinearIssueListStatus('all');
useUIStore.getState().setLinearIssueListAssignee('me');
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListPriority('urgent');
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
expect(useUIStore.getState().linearIssueListAssignee).toBe('me');
expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng');
expect(useUIStore.getState().linearIssueListPriority).toBe('urgent');
});
test('resets status, assignee, team, and priority together', () => {
useUIStore.getState().setLinearIssueListStatus('todo');
useUIStore.getState().setLinearIssueListAssignee('me');
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListPriority('urgent');
useUIStore.getState().resetLinearIssueListFilters();
expect(useUIStore.getState().linearIssueListStatus).toBe('all');
expect(useUIStore.getState().linearIssueListAssignee).toBe('any');
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
expect(useUIStore.getState().linearIssueListPriority).toBe('all');
});
test('treats a blank team id as all teams', () => {
useUIStore.getState().setLinearIssueListTeamId('team-eng');
useUIStore.getState().setLinearIssueListTeamId(' ');
expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS);
});
test('stores a one-shot Linear issue identifier for the rail panel', () => {
useUIStore.getState().setLinearIssueFocus(' ENG-12 ');
expect(useUIStore.getState().linearIssueFocus).toBe('ENG-12');
useUIStore.getState().setLinearIssueFocus(' ');
expect(useUIStore.getState().linearIssueFocus).toBeNull();
useUIStore.getState().setLinearIssueFocus('ENG-12');
useUIStore.getState().setLinearIssueFocus(null);
expect(useUIStore.getState().linearIssueFocus).toBeNull();
});
});
+92 -5
View File
@@ -7,14 +7,14 @@ import type { ShortcutCombo } from '@/lib/shortcuts';
import type { DraftStarterRef } from '@/lib/draftStarters';
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import type { TerminalShell } from '@/lib/api/types';
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -40,6 +40,37 @@ function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
return value === 'vim' ? 'vim' : 'default';
}
export const LINEAR_ISSUE_LIST_ALL_TEAMS = 'all';
function sanitizeLinearIssueListStatus(value: unknown): LinearIssueListStatus {
return value === 'all'
|| value === 'backlog'
|| value === 'todo'
|| value === 'started'
|| value === 'inReview'
|| value === 'completed'
|| value === 'canceled'
|| value === 'duplicate'
? value
: 'all';
}
function sanitizeLinearIssueListAssignee(value: unknown): LinearIssueListAssignee {
return value === 'me' || value === 'any' ? value : 'any';
}
function sanitizeLinearIssueListTeamId(value: unknown): string {
if (typeof value !== 'string') return LINEAR_ISSUE_LIST_ALL_TEAMS;
const teamId = value.trim();
return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS;
}
function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority {
return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all'
? value
: 'all';
}
type ContextPanelTab = {
id: string;
mode: ContextPanelMode;
@@ -342,7 +373,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
// Legacy 'preview' tabs are converted to 'browser' by the v14 migration;
// anything still carrying an unknown mode here is discarded rather than
// resurrected into a tab the panel cannot render.
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'linear' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
continue;
}
@@ -618,7 +649,7 @@ const sanitizeContextPanelByDirectory = (
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'notes' || mode === 'terminal')
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
) {
@@ -787,6 +818,12 @@ interface UIStore {
/** Width of the walkthrough table of contents, in pixels. */
walkthroughTocWidth: number;
gitChangesViewMode: 'flat' | 'tree';
linearIssueListStatus: LinearIssueListStatus;
linearIssueListAssignee: LinearIssueListAssignee;
linearIssueListTeamId: string;
linearIssueListPriority: LinearIssueListPriority;
/** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */
linearIssueFocus: string | null;
isTimelineDialogOpen: boolean;
isPromptNavigatorPanelOpen: boolean;
isImagePreviewOpen: boolean;
@@ -983,6 +1020,12 @@ interface UIStore {
setDiffWrapLines: (wrap: boolean) => void;
setWalkthroughTocWidth: (width: number) => void;
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
setLinearIssueListStatus: (status: LinearIssueListStatus) => void;
setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void;
setLinearIssueListTeamId: (teamId: string) => void;
setLinearIssueListPriority: (priority: LinearIssueListPriority) => void;
resetLinearIssueListFilters: () => void;
setLinearIssueFocus: (identifier: string | null) => void;
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
setPromptNavigatorPanelOpen: (open: boolean) => void;
@@ -1140,6 +1183,11 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: false,
walkthroughTocWidth: 224,
gitChangesViewMode: 'flat',
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
linearIssueFocus: null,
isTimelineDialogOpen: false,
isPromptNavigatorPanelOpen: false,
isImagePreviewOpen: false,
@@ -2055,7 +2103,37 @@ export const useUIStore = create<UIStore>()(
setGitChangesViewMode: (mode) => {
set({ gitChangesViewMode: mode });
},
setLinearIssueListStatus: (status) => {
set({ linearIssueListStatus: sanitizeLinearIssueListStatus(status) });
},
setLinearIssueListAssignee: (assignee) => {
set({ linearIssueListAssignee: sanitizeLinearIssueListAssignee(assignee) });
},
setLinearIssueListTeamId: (teamId) => {
set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) });
},
setLinearIssueListPriority: (priority) => {
set({ linearIssueListPriority: sanitizeLinearIssueListPriority(priority) });
},
resetLinearIssueListFilters: () => {
set({
linearIssueListStatus: 'all',
linearIssueListAssignee: 'any',
linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS,
linearIssueListPriority: 'all',
});
},
setLinearIssueFocus: (identifier) => {
const trimmed = identifier?.trim() ?? '';
set({ linearIssueFocus: trimmed || null });
},
setInputBarOffset: (offset) => {
set({ inputBarOffset: offset });
},
@@ -2712,6 +2790,11 @@ export const useUIStore = create<UIStore>()(
}
}
state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus);
state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee);
state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId);
state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority);
state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap);
state.largeTextPasteBehavior = normalizeLargeTextPasteBehavior(state.largeTextPasteBehavior);
@@ -2789,6 +2872,10 @@ export const useUIStore = create<UIStore>()(
diffWrapLines: state.diffWrapLines,
walkthroughTocWidth: state.walkthroughTocWidth,
gitChangesViewMode: state.gitChangesViewMode,
linearIssueListStatus: state.linearIssueListStatus,
linearIssueListAssignee: state.linearIssueListAssignee,
linearIssueListTeamId: state.linearIssueListTeamId,
linearIssueListPriority: state.linearIssueListPriority,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,