Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions extensions/linear/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Linear Changelog

## [Multi-Workspace Support] - {PR_MERGE_DATE}

- Connect any number of Linear workspaces (Manage Workspaces command: add, switch, re-authenticate, log out)
- Workspace dropdown on every list command, workspace field on Create Issue / Create Project (with per-workspace remembered defaults), optional one-shot `workspace` argument on Quick Add Comment to Issue, and a dedicated Create Issue for Myself in Workspace command
- Menu bar notifications aggregate all connected workspaces
- AI tools accept a workspace and a new `get-workspaces` tool lists the connected ones

## [My Issues Sub Views] - 2026-07-30

- Add a dropdown to the "My Issues" command to switch between the Assigned, Created, and Subscribed sub views, matching the Linear app. Assigned stays the default.
Expand Down
24 changes: 23 additions & 1 deletion extensions/linear/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,26 @@ The Linear extension brings the speed, quality and joy of the app to every corne

**Q2. How do I switch workspaces?**

**Ans.** You can't switch workspaces on the fly; however, you can go to `Preferences`, `click` "Logout" then run any `command` now selecting the appropriate workspace.
**Ans.** Connect additional workspaces with the **Manage Workspaces** command, then switch using the workspace dropdown on any list command, the Workspace field on forms, the optional `workspace` argument on **Quick Add Comment to Issue**, or the dedicated **Create Issue for Myself in Workspace** command.

## Multiple Workspaces

The extension supports any number of Linear workspaces — including the same workspace
connected under two different accounts.

- **Add a workspace:** open **Manage Workspaces** → **Add Workspace** (⌘N). Linear has no
workspace picker on its consent page: first switch to the target workspace at
[linear.app](https://linear.app) (top-left workspace switcher), then approve the grant.
- **Switch:** every list command has a Workspace dropdown (visible once you have two or
more workspaces); forms have a Workspace field; **Quick Add Comment to Issue** accepts an
optional `workspace` argument (URL key, account email, or unique name prefix) that acts
once without changing your default, and a dedicated **Create Issue for Myself in
Workspace** command targets any workspace by URL key, account email, or unique name
prefix without changing your default.
- **Menu bar:** unread notifications aggregate across all connected workspaces, one
section each.
- **Log out:** use Manage Workspaces (its rows show the workspace and account, unlike
Raycast's built-in login list). Logging out removes the login from Raycast; the grant
at Linear expires on its own within 24 hours.
- **Preferences** (like a preferred team key) apply per command and are matched inside
whichever workspace the command acts in.
101 changes: 100 additions & 1 deletion extensions/linear/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"description": "Select the field to focus automatically once the form is rendered",
"default": "title",
"data": [
{
"title": "Workspace",
"value": "workspaceKey"
},
{
"title": "Team",
"value": "teamId"
Expand Down Expand Up @@ -208,6 +212,59 @@
}
]
},
{
"name": "create-issue-for-myself-in-workspace",
"title": "Create Issue for Myself in Workspace",
"description": "Create and assign a new issue to yourself in a specific workspace.",
"mode": "no-view",
"preferences": [
{
"name": "preferredTeamKey",
"type": "textfield",
"title": "Preferred Team",
"placeholder": "RAY",
"description": "Specify the Key (e.g. RAY) of the preferred team. If not specified, the first team is used.",
"required": false
},
{
"name": "preferredStatusName",
"type": "textfield",
"title": "Preferred Status",
"placeholder": "Todo",
"description": "Specify the name of the preferred status for new issues (e.g. Todo, Backlog). If not specified, uses Linear's default.",
"required": false
},
{
"name": "shouldCloseMainWindow",
"type": "checkbox",
"title": "Advanced",
"label": "Close window immediately",
"description": "When enabled, the Raycast window is closed immediately, allowing you to carry on with other work.",
"default": false,
"required": false
}
],
"arguments": [
{
"name": "workspace",
"placeholder": "Workspace",
"type": "text",
"required": true
},
{
"name": "title",
"placeholder": "Title",
"type": "text",
"required": true
},
{
"name": "description",
"placeholder": "Description",
"type": "text",
"required": false
}
]
},
{
"name": "quick-add-comment-to-issue",
"title": "Quick Add Comment to Issue",
Expand Down Expand Up @@ -236,6 +293,12 @@
"placeholder": "Issue ID",
"type": "text",
"required": true
},
{
"name": "workspace",
"placeholder": "Workspace",
"type": "text",
"required": false
}
]
},
Expand All @@ -244,6 +307,12 @@
"title": "Favorites",
"description": "Browse through your Linear favorites.",
"mode": "view"
},
{
"name": "manage-workspaces",
"title": "Manage Workspaces",
"description": "Add, switch, re-authenticate, or log out of Linear workspaces.",
"mode": "view"
}
],
"tools": [
Expand Down Expand Up @@ -356,10 +425,15 @@
"name": "update-project-milestone",
"title": "Update an Existing Project Milestone",
"description": "Updates an existing project milestone in Linear"
},
{
"name": "get-workspaces",
"title": "Get Workspaces",
"description": "List the connected Linear workspaces with their workspaceId, name, URL key, account email, and which one is active. Call this before acting in a specific workspace and pass its workspaceId to other tools."
}
],
"ai": {
"instructions": "- Always format object titles and names as markdown links using the object's URL. Example: Instead of saying \"Issue **Implement user profile page** has been created', say \"Issue [Implement user profile page](https://linear.app/company/issue/ISS-2) has been created\"\\n- Do not use names or titles as IDs in the system. Use the actual ID of the object\\n- When there is no exact match of issue / label titles with the provided name, analyze the input and titles of existing objects and use such objects only if the meaning is the same. Do not use objects that are not related to the input\\n- If user asks for his issues, it means issues assigned to him. Use `get-current-user` tool to get the user id and use it to filter\\n- \\n- If user ask to close the issue, it means to move it to done status\\n- When user asks about inbox they mean notifications",
"instructions": "- Always format object titles and names as markdown links using the object's URL. Example: Instead of saying \"Issue **Implement user profile page** has been created', say \"Issue [Implement user profile page](https://linear.app/company/issue/ISS-2) has been created\"\\n- Do not use names or titles as IDs in the system. Use the actual ID of the object\\n- When there is no exact match of issue / label titles with the provided name, analyze the input and titles of existing objects and use such objects only if the meaning is the same. Do not use objects that are not related to the input\\n- If user asks for his issues, it means issues assigned to him. Use `get-current-user` tool to get the user id and use it to filter\\n- \\n- If user ask to close the issue, it means to move it to done status\\n- When user asks about inbox they mean notifications\\n- If the user names a workspace, or more than one workspace might be connected, call `get-workspaces` once, then pass its `workspaceId` value to EVERY subsequent tool call in the conversation, and state in your answer which workspace you acted in\\n- Never guess or construct workspaceId values; only use values returned by `get-workspaces`. If the named workspace is not in the list, say so and list the available ones\\n- If no workspace is mentioned and only one is connected, omit `workspaceId`",
"evals": [
{
"input": "@linear what's in my inbox",
Expand Down Expand Up @@ -1568,6 +1642,31 @@
}
}
]
},
{
"input": "@linear list my workspaces",
"mocks": {
"get-workspaces": [
{ "workspaceId": "org-a:user-1", "name": "ConceptM", "urlKey": "conceptm", "userEmail": "steve@conceptm.com", "isActive": true },
{ "workspaceId": "org-b:user-2", "name": "flox", "urlKey": "floxdotdev", "userEmail": "stevemorin@flox.dev", "isActive": false }
]
},
"expected": [{ "callsTool": "get-workspaces" }]
},
{
"input": "@linear create an issue titled \"Test multi\" in the flox workspace for team ENG",
"mocks": {
"get-workspaces": [
{ "workspaceId": "org-a:user-1", "name": "ConceptM", "urlKey": "conceptm", "userEmail": "steve@conceptm.com", "isActive": true },
{ "workspaceId": "org-b:user-2", "name": "flox", "urlKey": "floxdotdev", "userEmail": "stevemorin@flox.dev", "isActive": false }
],
"get-teams": [{ "id": "team-eng", "name": "Engineering", "key": "ENG" }],
"create-issue": { "success": true, "issue": { "id": "issue-1", "identifier": "ENG-1", "title": "Test multi", "url": "https://linear.app/floxdotdev/issue/ENG-1" } }
},
"expected": [
{ "callsTool": "get-workspaces" },
{ "callsTool": { "name": "create-issue", "arguments": { "workspaceId": "org-b:user-2" } } }
]
}
]
},
Expand Down
49 changes: 42 additions & 7 deletions extensions/linear/src/active-cycle.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,50 @@
import { Action, ActionPanel, Icon, List } from "@raycast/api";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";

import { getActiveCycleIssues } from "./api/getIssues";
import CreateIssueForm from "./components/CreateIssueForm";
import StateIssueList from "./components/StateIssueList";
import View from "./components/View";
import { useWorkspaces } from "./components/WorkspaceContext";
import {
isWorkspaceDropdownValue,
workspaceValueToKey,
WorkspaceDropdownSection,
} from "./components/WorkspaceDropdown";
import { getTeamIcon } from "./helpers/teams";
import useIssues from "./hooks/useIssues";
import useMe from "./hooks/useMe";
import usePriorities from "./hooks/usePriorities";
import useTeams from "./hooks/useTeams";
import { useWorkspaceCachedState } from "./hooks/useWorkspaceCachedState";

function ActiveCycle() {
const [teamQuery, setTeamQuery] = useState<string>("");
const { teams, org, supportsTeamTypeahead, isLoadingTeams } = useTeams(teamQuery);
const [selectedTeam, setSelectedTeam] = useState<string>("");
const { switchWorkspace } = useWorkspaces();
const [storedTeam, setStoredTeam] = useWorkspaceCachedState<string>("active-cycle-team", "");
// Restore validation (§4.5) runs ONCE against the initial, query-less team load —
// NOT against typeahead results, which would transiently "invalidate" the stored team
// and flip the visible cycle while the user types. State (not a ref) so a cold-cache
// resolution that lands after the first render still triggers a re-render to show it.
const [validatedTeam, setValidatedTeam] = useState<string | null>(null);
useEffect(() => {
if (validatedTeam === null && teams && teamQuery === "") {
setValidatedTeam(teams.some((team) => team.id === storedTeam) ? storedTeam : (teams[0]?.id ?? ""));
}
// Intentional deps: validatedTeam and storedTeam are read but not tracked —
// this must run only when teams/teamQuery change, not on every storedTeam update.
}, [teams, teamQuery]);
const selectedTeam = validatedTeam ?? "";

const { priorities, isLoadingPriorities } = usePriorities();
const { me, isLoadingMe } = useMe();

const cycleId = useMemo(() => {
return teams?.find((team) => team.id === selectedTeam)?.activeCycle?.id;
}, [selectedTeam]);
}, [selectedTeam, teams]);

const { issues, isLoadingIssues, mutateList } = useIssues(getActiveCycleIssues, [cycleId], {
const { issues, isLoadingIssues, mutateList } = useIssues((id?: string) => getActiveCycleIssues(id), [cycleId], {
execute: !!cycleId && cycleId.trim().length > 0,
});

Expand All @@ -32,15 +53,29 @@ function ActiveCycle() {
searchBarAccessory={
<List.Dropdown
tooltip="Change Team"
onChange={setSelectedTeam}
storeValue
value={selectedTeam || "-"}
onChange={(value) => {
if (isWorkspaceDropdownValue(value)) {
switchWorkspace(workspaceValueToKey(value));
return;
}
if (value !== "-" && value !== selectedTeam) {
setValidatedTeam(value);
setStoredTeam(value); // S9: ignore mount echo (guarded by the !== check)
}
}}
{...(supportsTeamTypeahead && {
throttle: true,
onSearchTextChange: setTeamQuery,
isLoading: isLoadingTeams,
})}
>
{(!teams || teams.length === 0) && (
<WorkspaceDropdownSection />
{/* Also render during the pre-validation frame (validatedTeam === null): on a warm
cache teams can already be populated while selectedTeam still falls back to "-"
(S9) — the controlled value must always match a rendered item, or Raycast's
first-item snap could fire a persisted workspace switch. */}
{(!teams || teams.length === 0 || validatedTeam === null) && (
<List.Dropdown.Item title="No team" value="-" key="-" icon={Icon.TwoPeople} />
)}
{teams?.map((team) => (
Expand Down
22 changes: 11 additions & 11 deletions extensions/linear/src/api/attachments.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { readFile } from "fs/promises";
import path from "path";

import { UploadFile } from "@linear/sdk";
import { LinearClient, UploadFile } from "@linear/sdk";
import { fileTypeFromFile } from "file-type";

import { getLinearClient } from "./linearClient";
import { resolveClient } from "./linearClient";

const DEFAULT_CONTENT_TYPE = "application/octet-stream";

Expand All @@ -20,8 +20,8 @@ export type UploadedFile = {
name: string;
};

export async function uploadFile(filePath: string): Promise<UploadedFile> {
const { graphQLClient } = getLinearClient();
export async function uploadFile(filePath: string, client?: LinearClient): Promise<UploadedFile> {
const { graphQLClient } = resolveClient(client);

const buffer = await readFile(filePath);
const type = await fileTypeFromFile(filePath);
Expand Down Expand Up @@ -81,14 +81,14 @@ function escapeMarkdownLabel(value: string) {
return value.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]");
}

export async function appendFileAttachments(markdown: string, attachmentPaths?: string[]) {
export async function appendFileAttachments(markdown: string, attachmentPaths?: string[], client?: LinearClient) {
if (!attachmentPaths?.length) {
return markdown;
}

const files: UploadedFile[] = [];
for (const filePath of attachmentPaths) {
files.push(await uploadFile(filePath));
files.push(await uploadFile(filePath, client));
}
const attachments = files.map(({ assetUrl, contentType, name }) => {
const label = escapeMarkdownLabel(name);
Expand All @@ -103,10 +103,10 @@ export type CreateAttachmentPayload = {
url: string;
};

export async function createAttachment(payload: CreateAttachmentPayload) {
const { graphQLClient } = getLinearClient();
export async function createAttachment(payload: CreateAttachmentPayload, client?: LinearClient) {
const { graphQLClient } = resolveClient(client);

const file = await uploadFile(payload.url);
const file = await uploadFile(payload.url, client);

const attachmentInput = `issueId: "${payload.issueId}", title: "${file.name}", url: "${file.assetUrl}"`;

Expand All @@ -129,8 +129,8 @@ export async function createAttachment(payload: CreateAttachmentPayload) {
return { success: data?.attachmentCreate.success, id: data?.attachmentCreate.attachment.id };
}

export async function attachLinkUrl(payload: CreateAttachmentPayload) {
const { graphQLClient } = getLinearClient();
export async function attachLinkUrl(payload: CreateAttachmentPayload, client?: LinearClient) {
const { graphQLClient } = resolveClient(client);

const attachmentInput = `issueId: "${payload.issueId}", url: "${payload.url}"`;

Expand Down
12 changes: 7 additions & 5 deletions extensions/linear/src/api/createIssue.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getLinearClient } from "../api/linearClient";
import { LinearClient } from "@linear/sdk";

import { resolveClient } from "../api/linearClient";

import { IssueFragment, IssueResult } from "./getIssues";

Expand All @@ -18,8 +20,8 @@ export type CreateIssuePayload = {
parentId?: string;
};

export async function createIssue(payload: CreateIssuePayload) {
const { graphQLClient } = getLinearClient();
export async function createIssue(payload: CreateIssuePayload, client?: LinearClient) {
const { graphQLClient } = resolveClient(client);

const title = payload.title.replace(/"/g, "\\$&");
const description = payload.description?.replace(/\n/g, "\\n")?.replace(/"/g, "\\$&");
Expand Down Expand Up @@ -89,8 +91,8 @@ type CreateSubIssuePayload = {
stateId?: string;
};

export async function createSubIssue(payload: CreateSubIssuePayload) {
const { graphQLClient } = getLinearClient();
export async function createSubIssue(payload: CreateSubIssuePayload, client?: LinearClient) {
const { graphQLClient } = resolveClient(client);

const title = payload.title.replace(/"/g, "\\$&");
const description = payload.description?.replace(/\n/g, "\\n").replace(/"/g, "\\$&");
Expand Down
8 changes: 4 additions & 4 deletions extensions/linear/src/api/deleteNotification.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Notification } from "@linear/sdk";
import { LinearClient, Notification } from "@linear/sdk";

import { getLinearClient } from "../api/linearClient";
import { resolveClient } from "../api/linearClient";

export async function deleteNotification(id: Notification["id"]) {
const { graphQLClient } = getLinearClient();
export async function deleteNotification(id: Notification["id"], client?: LinearClient) {
const { graphQLClient } = resolveClient(client);

const { data } = await graphQLClient.rawRequest<
{ notificationArchive: { success: boolean } },
Expand Down
Loading
Loading