Skip to content
Merged
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 apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ import { RightPanelTabs } from "./RightPanelTabs";
import { AgentsPanel } from "./AgentsPanel";
import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog";
import { LinearIssuePickerHost } from "./chat/LinearIssuePicker";
import { PullRequestAttachPickerHost } from "./chat/PullRequestAttachPicker";
import { openStartFromPicker, StartFromPickerHost } from "./chat/StartFromPicker";
import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel";
import { useDeviceState } from "~/state/device";
Expand Down Expand Up @@ -10858,6 +10859,12 @@ export default function ChatView(props: ChatViewProps) {
</AlertDialog>
<LinkPullRequestDialogHost />
<LinearIssuePickerHost />
{supportsPullRequests && activeProject ? (
<PullRequestAttachPickerHost
environmentId={activeProject.environmentId}
projectId={activeProject.id}
/>
) : null}
{canCheckoutPullRequestIntoThread && activeProject && activeProjectRef ? (
<StartFromPickerHost
environmentId={activeProject.environmentId}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "@t3tools/contracts";
import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
import { openLinearIssuePicker } from "./chat/LinearIssuePicker";
import { openPullRequestAttachPicker } from "./chat/PullRequestAttachPicker";
import { openStartFromPicker } from "./chat/StartFromPicker";
import * as Option from "effect/Option";
import {
Expand Down Expand Up @@ -1873,6 +1874,21 @@ function OpenCommandPaletteDialog(props: {
openLinearIssuePicker(composerThreadRef);
},
});
const composerEnvironment = environments.find(
(environment) => environment.environmentId === composerThreadRef.environmentId,
);
if (composerEnvironment?.serverConfig?.environment.capabilities.pullRequests === true) {
actionItems.push({
kind: "action",
value: "action:attach-pull-request",
searchTerms: ["pull request", "pr", "attach", "context"],
title: "Attach pull request",
icon: <PullRequestGlyph.pullRequest className={ITEM_ICON_CLASS} />,
run: async () => {
openPullRequestAttachPicker(composerThreadRef);
},
});
}
}

actionItems.push({
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7292,6 +7292,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
/>
<ComposerAttachMenu
threadRef={routeThreadRef}
pullRequestsAvailable={pullRequestProjectId !== null}
onAttachFiles={() => attachmentInputRef.current?.click()}
/>
</>
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/chat/ComposerAttachMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ import { Button } from "../ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
import { useComposerMenuProps } from "./composerEventScope";
import { openLinearIssuePicker } from "./LinearIssuePicker";
import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import { openPullRequestAttachPicker } from "./PullRequestAttachPicker";

/** The composer's attach button: asks what to attach, files or a Linear issue. */
/** The composer's attach button: asks what to attach, files, a Linear issue or a pull request. */
export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: {
threadRef: ScopedThreadRef;
/** Whether the thread's project can list pull requests to attach. */
pullRequestsAvailable: boolean;
onAttachFiles: () => void;
}) {
const composerMenuProps = useComposerMenuProps();
Expand All @@ -30,6 +34,12 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: {
<LinearIcon />
Linear issue
</MenuItem>
{props.pullRequestsAvailable ? (
<MenuItem onClick={() => openPullRequestAttachPicker(props.threadRef)}>
<PullRequestGlyph.pullRequest />
Pull request
</MenuItem>
) : null}
</MenuPopup>
</Menu>
);
Expand Down
179 changes: 179 additions & 0 deletions apps/web/src/components/chat/PullRequestAttachPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId, ProjectId, ScopedThreadRef } from "@t3tools/contracts";
import { Atom } from "effect/unstable/reactivity";
import { useMemo, useState } from "react";

import { useComposerDraftStore } from "~/composerDraftStore";
import { cn } from "~/lib/utils";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { usePullRequestList } from "~/state/pullRequests";
import { useDebouncedValue } from "~/state/queries";
import { CommandPaletteContent } from "../CommandPaletteContent";
import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDetail.logic";
import { PULL_REQUEST_STATE_PRESENTATION, PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import type { EnvironmentPullRequestEntry } from "../pullRequest/pullRequestList.logic";
import {
CommandCollection,
CommandDialog,
CommandDialogPopup,
CommandGroup,
CommandItem,
CommandList,
} from "../ui/command";
import { PullRequestHoverPreview } from "./StartFromPreviews";
import {
DEFAULT_PULL_REQUEST_PICKER_VIEW,
narrowPickerPullRequests,
PullRequestPickerFilterBar,
} from "./StartFromPullRequestFilters";

const SEARCH_DEBOUNCE_MS = 300;
const PULL_REQUEST_LIMIT = 50;
const EMPTY_PULL_REQUESTS: ReadonlyArray<EnvironmentPullRequestEntry> = [];
const NO_THREADS: never[] = [];

/**
* The thread whose composer the picker attaches to, set by whichever entry point asked (the
* attach menu, the command palette) and rendered once by the chat view, so the picker outlives a
* palette that closes the moment its command runs.
*/
const pullRequestAttachPickerThreadAtom = Atom.make<ScopedThreadRef | null>(null).pipe(
Atom.keepAlive,
Atom.withLabel("chat:pull-request-attach-picker-thread"),
);

export function openPullRequestAttachPicker(threadRef: ScopedThreadRef): void {
appAtomRegistry.set(pullRequestAttachPickerThreadAtom, threadRef);
}

function closePullRequestAttachPicker(): void {
appAtomRegistry.set(pullRequestAttachPickerThreadAtom, null);
}

/**
* Mounted by a chat view whose project can list pull requests; shows the picker for whichever
* thread asked for it. A picked pull request lands as the same chip `#` inserts.
*/
export function PullRequestAttachPickerHost(props: {
environmentId: EnvironmentId;
projectId: ProjectId;
}) {
const threadRef = useAtomValue(pullRequestAttachPickerThreadAtom);
if (threadRef === null) return null;
return <PullRequestAttachPickerDialog {...props} threadRef={threadRef} />;
}

function PullRequestAttachPickerDialog(props: {
environmentId: EnvironmentId;
projectId: ProjectId;
threadRef: ScopedThreadRef;
}) {
const { environmentId, projectId, threadRef } = props;
const [query, setQuery] = useState("");
const trimmedQuery = query.trim();
const debouncedQuery = useDebouncedValue(trimmedQuery, SEARCH_DEBOUNCE_MS);
const [view, setView] = useState(DEFAULT_PULL_REQUEST_PICKER_VIEW);
const targets = useMemo(
() => [
{
environmentId,
input: {
state: view.state,
projectId,
limit: PULL_REQUEST_LIMIT,
...(Object.keys(view.filters).length > 0 ? { filters: view.filters } : {}),
...(debouncedQuery.length > 0 ? { query: debouncedQuery } : {}),
},
},
],
[debouncedQuery, environmentId, projectId, view],
);
const pullRequests = usePullRequestList(targets);
const loaded = pullRequests.data?.entries ?? EMPTY_PULL_REQUESTS;
const entries = useMemo(
() => narrowPickerPullRequests(loaded, pullRequests.data?.viewers ?? {}, view, debouncedQuery),
[debouncedQuery, loaded, pullRequests.data?.viewers, view],
);

const select = (entry: EnvironmentPullRequestEntry) => {
useComposerDraftStore
.getState()
.addReviewComment(threadRef, buildPullRequestReferenceContext(entry));
closePullRequestAttachPicker();
};

const status =
entries.length > 0
? null
: pullRequests.isPending || trimmedQuery !== debouncedQuery
? "Loading pull requests…"
: (pullRequests.error ?? "No pull requests match these filters.");

return (
<CommandDialog
open
onOpenChange={(open) => {
if (!open) closePullRequestAttachPicker();
}}
>
<CommandDialogPopup aria-label="Attach pull request" className="overflow-hidden">
<CommandPaletteContent
inputProps={{
placeholder: "Search pull requests by title, number, or author",
startAddon: <PullRequestGlyph.pullRequest />,
}}
inputAccessory={
<PullRequestPickerFilterBar entries={loaded} view={view} onChange={setView} />
}
footerActionLabel="Attach"
mode="none"
value={query}
onValueChange={setQuery}
>
{status !== null ? (
<div className="py-10 text-center text-muted-foreground text-sm">{status}</div>
) : (
<CommandList>
<CommandGroup items={[...entries]}>
<CommandCollection>
{(entry: EnvironmentPullRequestEntry) => {
const presentation =
PULL_REQUEST_STATE_PRESENTATION[entry.isDraft ? "draft" : entry.state];
return (
<CommandItem
key={entry.url}
value={entry.url}
onMouseDown={(event) => event.preventDefault()}
onClick={() => select(entry)}
>
<PullRequestHoverPreview
entry={entry}
threadRef={threadRef}
threads={NO_THREADS}
trigger={
<span className="flex min-w-0 flex-1 items-center gap-2">
<presentation.Icon
className={cn("size-4 shrink-0", presentation.toneClassName)}
/>
<span className="w-12 shrink-0 text-muted-foreground text-xs tabular-nums">
#{entry.number}
</span>
<span className="min-w-0 flex-1 truncate text-sm">{entry.title}</span>
<span className="w-24 shrink-0 truncate text-end text-muted-foreground/70 text-xs">
{entry.author?.login ?? ""}
</span>
</span>
}
/>
</CommandItem>
);
}}
</CommandCollection>
</CommandGroup>
</CommandList>
)}
</CommandPaletteContent>
</CommandDialogPopup>
</CommandDialog>
);
}
7 changes: 6 additions & 1 deletion docs/fork-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,18 @@ When the browser is on another device, the user pastes the redirect URL back ins
`T3CODE_LINEAR_CLIENT_ID` points a fork at its own Linear app.

Issues attach to messages as a `linear-issue` context chip: from the composer's attach menu (which
now asks between files and a Linear issue), the `#` menu beside pull requests, the command palette,
now asks between files, a Linear issue, and a pull request), the `#` menu beside pull requests, the command palette,
and the mobile attach menu. The web picker filters by assignee, team, project, milestone, status,
priority, and label, and sorts by the fields Linear's API offers. The server renders the issue to
capped markdown when it is attached, and that snapshot is inlined into the prompt for every
provider. An **Open Linear links in** setting can send issue links to the Linear desktop app
(`linear://`), which the desktop shell's external-URL allowlist permits.

The attach menu's pull request option, also in the web command palette, opens a searchable picker
([`PullRequestAttachPicker.tsx`](../apps/web/src/components/chat/PullRequestAttachPicker.tsx)) and
inserts the same context chip as picking the pull request from the `#` menu. It is not a thread
link.

Code: `apps/server/src/linear/`, `packages/contracts/src/linear.ts`, `LinearIssueContextRecord` in
`packages/contracts/src/composerContext.ts`, `packages/client-runtime/src/state/linear.ts`,
`apps/web/src/components/settings/LinearSettings.tsx`,
Expand Down