From f71bca6d18b1fe0c249752b8595ef880d2c573a9 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Wed, 21 Oct 2026 17:39:19 +0100 Subject: [PATCH] fix(web): restore the # menu's Linear, Slack, checkout and other tabs lost in the upstream sync The upstream merge in #415 rewrote ChatComposer and dropped the fork's `#` reference menu wiring, leaving `#` as a pull-request-only search. The tab hooks and logic survived but nothing used them. Move the menu state into useComposerReferenceMenu and wire it back into the composer, along with cmd/ctrl+click multi-select and Cmd-Enter checkout linking. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 224 +++++++++++--- .../chat/useComposerReferenceMenu.ts | 285 ++++++++++++++++++ 2 files changed, 475 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/chat/useComposerReferenceMenu.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ea11e6d373b91..a3d3c47149286 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -311,7 +311,20 @@ import { useRepositoryContextRecords } from "~/repositoryContextStore"; import { useThreadTabContextRecords } from "~/threadTabContextStore"; import { providerModelKey } from "~/modelOrdering"; import { ComposerPrimaryActions, type ComposerSendSurface } from "./ComposerPrimaryActions"; -import { useComposerCheckoutItems } from "./useComposerCheckoutItems"; +import { useAttachCheckout, useComposerCheckoutItems } from "./useComposerCheckoutItems"; +import { useAttachGitHubIssue } from "./GitHubIssuePicker"; +import { useAttachLinearIssue } from "./LinearIssuePicker"; +import { useAttachNotionPage } from "./NotionPagePicker"; +import { useAttachGranolaNote } from "./GranolaNotePicker"; +import { slackGetThreadInput, useAttachSlackMessage } from "./SlackMessagePicker"; +import { discordGetThreadInput, useAttachDiscordMessage } from "./DiscordMessagePicker"; +import { attachRepository } from "./RepositoryAttachPicker"; +import { recordComposerReferenceRecents } from "./composerReferenceRecents"; +import { + type ComposerReferenceItem as ReferenceMenuItem, + isComposerReferenceItem as isReferenceMenuItem, +} from "./composerReferenceMenu.logic"; +import { useComposerReferenceMenu } from "./useComposerReferenceMenu"; import { ComposerFooterModeControls, ComposerFooterPrimaryActions, @@ -2398,12 +2411,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; + const referenceRemoteUrls = (() => { + if (composerTrigger?.kind !== "pull-request") return []; + const referencedIds = new Set( + collectComposerContextReferences(prompt) + .filter((reference) => reference.kind === "repository") + .map((reference) => reference.contextId), + ); + return [ + ...promptHistoryMessages.flatMap((message) => + (message.context?.records ?? []).flatMap((record) => + record.kind === "repository" && "remoteUrl" in record ? [record.remoteUrl] : [], + ), + ), + ...composerRepositoryContexts + .filter((record) => referencedIds.has(record.contextId)) + .map((record) => record.remoteUrl), + ]; + })(); + const referenceMenu = useComposerReferenceMenu({ + environmentId, + trigger: composerTrigger, + projectId: pullRequestProjectId, + repository: pullRequestRepository, + remoteUrls: referenceRemoteUrls, + threadPullRequests: threadPullRequests ?? [], + githubIssueCwd, + }); + // Items picked with cmd/ctrl+click, across tabs, held until this `#` token closes. + const [referenceSelection, setReferenceSelection] = useState<{ + rangeStart: number; + items: ReadonlyArray; + } | null>(null); + const selectedReferenceItems = + composerTrigger?.kind === "pull-request" && + referenceSelection?.rangeStart === composerTrigger.rangeStart + ? referenceSelection.items + : []; + const selectedReferenceItemsRef = useRef(selectedReferenceItems); + useLayoutEffect(() => { + selectedReferenceItemsRef.current = selectedReferenceItems; + }); + const selectedReferenceItemIds = new Set(selectedReferenceItems.map((item) => item.id)); + const attachCheckout = useAttachCheckout(selectedProvider); + const attachLinearIssue = useAttachLinearIssue(); + const attachNotionPage = useAttachNotionPage(); + const attachGranolaNote = useAttachGranolaNote(); + const attachSlackMessage = useAttachSlackMessage(); + const attachDiscordMessage = useAttachDiscordMessage(); + const attachGitHubIssue = useAttachGitHubIssue(); const { items: composerMenuItemsFromTrigger, - isLoading: isComposerMenuLoading, - emptyState: composerMenuEmptyState, + isLoading: isTriggerMenuLoading, + emptyState: triggerMenuEmptyState, } = useComposerMenuItems({ - composerTrigger, + // Fork: `#` is served by the reference menu, which also searches pull requests. + composerTrigger: referenceMenu.open ? null : composerTrigger, environmentId, gitCwd, activeThreadId, @@ -2417,6 +2480,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, }); + const isComposerMenuLoading = referenceMenu.open ? referenceMenu.isLoading : isTriggerMenuLoading; + const composerMenuEmptyState = referenceMenu.open + ? referenceMenu.emptyState + : triggerMenuEmptyState; const pathMenuBase = composerTrigger?.kind === "path" ? withThreadTabMenuItems( @@ -2470,13 +2537,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : modelMenu.prefersModels ? "models" : "files"; - const composerMenuItems = !isPathTrigger - ? pathMenuBase - : pathTab === "models" - ? [...modelMenu.items] - : pathTab === "agents" - ? [...agentMenu.items] - : pathMenuBase; + const composerMenuItems = referenceMenu.open + ? [...referenceMenu.items] + : !isPathTrigger + ? pathMenuBase + : pathTab === "models" + ? [...modelMenu.items] + : pathTab === "agents" + ? [...agentMenu.items] + : pathMenuBase; const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -3512,8 +3581,77 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }; + /** + * Replaces the `#` token with the items' chips. Pull requests are captured inline; the rest + * land at the caret once fetched. + */ + const attachReferenceItems = useStableCallback( + ( + prompt: string, + trigger: ComposerTrigger, + items: ReadonlyArray, + options?: { readonly link?: boolean }, + ) => { + if (items.some((item) => item.type === "checkout" && !item.selectable)) return; + const pullRequestComments = items.flatMap((item) => + item.type === "pull-request" ? [buildPullRequestReferenceContext(item.pullRequest)] : [], + ); + const replacement = pullRequestComments + .map( + (comment) => `${formatInlineContextReference(reviewCommentContextReference(comment))} `, + ) + .join(""); + const replacementRangeEnd = + replacement.length > 0 + ? extendReplacementRangeForTrailingSpace(prompt, trigger.rangeEnd, replacement) + : trigger.rangeEnd; + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: prompt.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (!applied) return; + setComposerHighlightedItemId(null); + setReferenceSelection(null); + recordComposerReferenceRecents(environmentId, pullRequestProjectId, items); + for (const comment of pullRequestComments) { + addComposerDraftReviewComment(composerDraftTarget, comment, { appendReference: false }); + } + for (const item of items) { + if (item.type === "linear-issue") void attachLinearIssue(routeThreadRef, item.issueId); + if (item.type === "github-issue") void attachGitHubIssue(routeThreadRef, item.url); + if (item.type === "notion-page") void attachNotionPage(routeThreadRef, item.pageId); + if (item.type === "granola-note") void attachGranolaNote(routeThreadRef, item.noteId); + if (item.type === "slack-message") { + void attachSlackMessage(routeThreadRef, slackGetThreadInput(item.message, "thread")); + } + if (item.type === "discord-message") { + void attachDiscordMessage(routeThreadRef, discordGetThreadInput(item.message, "thread")); + } + if (item.type === "repository") { + attachRepository(routeThreadRef, { + nameWithOwner: item.nameWithOwner, + remoteUrl: item.remoteUrl, + }); + } + if (item.type === "checkout") { + attachCheckout(routeThreadRef, item, { link: options?.link === true }); + } + } + }, + ); + const commitReferenceSelection = useStableCallback(() => { + const items = selectedReferenceItemsRef.current; + if (items.length === 0) return false; + const { snapshot, trigger } = resolveActiveComposerTrigger(); + if (trigger?.kind !== "pull-request") return false; + attachReferenceItems(snapshot.value, trigger, items); + return true; + }); + const { onUsageLimitsCommand } = props; - const onSelectComposerItem = (item: ComposerCommandItem) => { + const onSelectComposerItem = ( + item: ComposerCommandItem, + options?: { toggle?: boolean; link?: boolean }, + ) => { if (composerSelectLockRef.current) return; composerSelectLockRef.current = true; window.requestAnimationFrame(() => { @@ -3600,31 +3738,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - if (item.type === "pull-request") { + if (isReferenceMenuItem(item)) { + if (trigger.kind !== "pull-request") return; + const selected = selectedReferenceItemsRef.current; + if (options?.toggle || selected.length > 0) { + setReferenceSelection({ + rangeStart: trigger.rangeStart, + items: selected.some((candidate) => candidate.id === item.id) + ? selected.filter((candidate) => candidate.id !== item.id) + : [...selected, item], + }); + return; + } if ( - trigger.kind !== "pull-request" || + item.type === "pull-request" && !composerMenuItemsRef.current.some((candidate) => candidate.id === item.id) ) { return; } - const comment = buildPullRequestReferenceContext(item.pullRequest); - const replacement = `${formatInlineContextReference( - reviewCommentContextReference(comment), - )} `; - const replacementRangeEnd = extendReplacementRangeForTrailingSpace( - snapshot.value, - trigger.rangeEnd, - replacement, - ); - const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { - expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), - }); - if (applied) { - addComposerDraftReviewComment(composerDraftTarget, comment, { - appendReference: false, - }); - setComposerHighlightedItemId(null); - } + attachReferenceItems(snapshot.value, trigger, [item], { link: options?.link === true }); return; } if (item.type === "subagent") { @@ -4156,6 +4288,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (!menuIsActive || event.isComposing || event.keyCode === 229) return false; dismissComposerTrigger(trigger); composerMenuOpenRef.current = false; + setReferenceSelection(null); return true; } if (menuIsActive && (submissionIntent === null || submissionIntent === "foreground")) { @@ -4169,8 +4302,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) nudgeComposerMenuHighlight("ArrowUp"); return true; } + if (key === "Enter" && commitReferenceSelection()) return true; if ((key === "Enter" || key === "Tab") && selectedItem) { - onSelectComposerItem(selectedItem); + onSelectComposerItem(selectedItem, { link: event.metaKey || event.ctrlKey }); return true; } } @@ -6940,7 +7074,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerSuggestionsVisible && composerMenuItems.length === 0 ? isComposerMenuLoading ? composerTriggerKind === "pull-request" - ? "Finding pull request..." + ? (referenceMenu.loadingText ?? "Finding pull request...") : "Searching workspace files..." : composerMenuEmptyState : ""} @@ -6967,6 +7101,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlightedStable} onSelect={onSelectComposerItemStable} + environmentId={environmentId} + {...(referenceMenu.open + ? { + ...(referenceMenu.tabs + ? { + tabs: { + options: referenceMenu.tabs, + activeId: referenceMenu.tab, + onSelect: referenceMenu.chooseTab, + }, + } + : {}), + ...(referenceMenu.loadingText + ? { loadingText: referenceMenu.loadingText } + : {}), + ...(referenceMenu.hint ? { hint: referenceMenu.hint } : {}), + selection: { + itemIds: selectedReferenceItemIds, + onCommit: commitReferenceSelection, + }, + } + : {})} {...(isPathTrigger ? { tabs: { diff --git a/apps/web/src/components/chat/useComposerReferenceMenu.ts b/apps/web/src/components/chat/useComposerReferenceMenu.ts new file mode 100644 index 0000000000000..d9e87df95b1b7 --- /dev/null +++ b/apps/web/src/components/chat/useComposerReferenceMenu.ts @@ -0,0 +1,285 @@ +import { + configuredContextRepositoryOwners, + describeContextRepositoryOwners, +} from "@t3tools/client-runtime/context-repositories"; +import type { EnvironmentId, ProjectId, ThreadPullRequestLink } from "@t3tools/contracts"; +import { useState } from "react"; + +import type { ComposerTrigger } from "../../composer-logic"; +import { useEnvironmentSettings } from "~/hooks/useSettings"; +import { useComposerReferenceRecents } from "./composerReferenceRecents"; +import { + type ComposerReferenceItem, + type ComposerReferenceTab, + composerReferenceTabForItem, + orderComposerReferenceItems, + predictComposerReferenceTab, +} from "./composerReferenceMenu.logic"; +import { useComposerCheckoutItems } from "./useComposerCheckoutItems"; +import { useComposerDiscordItems } from "./useComposerDiscordItems"; +import { useComposerGitHubIssueItems } from "./useComposerGitHubIssueItems"; +import { useComposerGranolaItems } from "./useComposerGranolaItems"; +import { useComposerLinearIssueItems } from "./useComposerLinearIssueItems"; +import { useComposerNotionItems } from "./useComposerNotionItems"; +import { useComposerPullRequestItems } from "./useComposerPullRequestItems"; +import { useComposerReferenceSources } from "./useComposerReferenceSources"; +import { useComposerRepositoryItems } from "./useComposerRepositoryItems"; +import { useComposerSlackItems } from "./useComposerSlackItems"; + +const REFERENCE_MENU_TABS: ReadonlyArray<{ + id: ComposerReferenceTab; + label: string; +}> = [ + { id: "checkouts", label: "Checkouts" }, + { id: "pull-requests", label: "Pull requests" }, + { id: "github-issues", label: "GitHub issues" }, + { id: "linear-issues", label: "Linear issues" }, + { id: "slack-messages", label: "Slack" }, + { id: "discord-messages", label: "Discord" }, + { id: "notion-pages", label: "Notion" }, + { id: "granola-notes", label: "Granola" }, + { id: "repositories", label: "Repositories" }, +]; +const REFERENCE_MENU_RESULT_LIMIT = 20; +const EMPTY_REFERENCE_MENU_ITEMS: ReadonlyArray = []; + +/** + * Fork: the `#` menu's tabs. Kept out of ChatComposer so upstream syncs, which rewrite the + * composer, have less to carry over. + */ +export function useComposerReferenceMenu(input: { + readonly environmentId: EnvironmentId; + readonly trigger: ComposerTrigger | null; + readonly projectId: ProjectId | null; + readonly repository: string | null; + /** Repositories the thread references, so `#123` also resolves their pull requests. */ + readonly remoteUrls: ReadonlyArray; + readonly threadPullRequests: ReadonlyArray; + readonly githubIssueCwd: string | null; +}) { + const { environmentId, trigger } = input; + const open = trigger?.kind === "pull-request"; + const query = open ? trigger.query : ""; + // A tab picked by hand holds until this `#` token closes; otherwise the query picks it. + const [tabChoice, setTabChoice] = useState<{ + rangeStart: number; + tab: ComposerReferenceTab; + } | null>(null); + const defaultRepositoryOwners = useEnvironmentSettings( + environmentId, + configuredContextRepositoryOwners, + ); + const sources = useComposerReferenceSources(environmentId, open); + const recents = useComposerReferenceRecents(environmentId, input.projectId); + const available = new Set( + REFERENCE_MENU_TABS.flatMap((tab) => + (tab.id === "linear-issues" && !sources.linear) || + (tab.id === "notion-pages" && !sources.notion) || + (tab.id === "granola-notes" && !sources.granola) || + (tab.id === "slack-messages" && !sources.slack) || + (tab.id === "discord-messages" && !sources.discord) || + (tab.id === "github-issues" && input.githubIssueCwd === null) + ? [] + : [tab.id], + ), + ); + const tabs = REFERENCE_MENU_TABS.filter((tab) => available.has(tab.id)); + const tabPickedByHand = + open && tabChoice?.rangeStart === trigger.rangeStart ? tabChoice.tab : null; + // Every available tab preloads its empty-query rows while `#` is open; only the visible tab + // searches the typed query. + const [predictedTab, setPredictedTab] = useState("pull-requests"); + const tab: ComposerReferenceTab = open ? (tabPickedByHand ?? predictedTab) : "pull-requests"; + + const pullRequestMenu = useComposerPullRequestItems({ + environmentId, + projectId: input.projectId, + repository: input.repository, + remoteUrls: input.remoteUrls, + pullRequests: input.threadPullRequests, + enabled: open, + active: tab === "pull-requests", + query, + }); + const linearIssueMenu = useComposerLinearIssueItems(environmentId, { + enabled: available.has("linear-issues"), + active: tab === "linear-issues", + query, + }); + const notionMenu = useComposerNotionItems(environmentId, { + enabled: available.has("notion-pages"), + active: tab === "notion-pages", + query, + }); + const granolaMenu = useComposerGranolaItems(environmentId, { + enabled: available.has("granola-notes"), + active: tab === "granola-notes", + query, + }); + const slackMenu = useComposerSlackItems(environmentId, { + enabled: available.has("slack-messages"), + active: tab === "slack-messages", + query, + }); + const discordMenu = useComposerDiscordItems(environmentId, { + enabled: available.has("discord-messages"), + active: tab === "discord-messages", + query, + }); + const githubIssueMenu = useComposerGitHubIssueItems(environmentId, input.githubIssueCwd, { + enabled: open, + active: tab === "github-issues", + query, + }); + const repositoryMenu = useComposerRepositoryItems(environmentId, defaultRepositoryOwners, { + enabled: open, + active: tab === "repositories", + query, + }); + const checkoutMenu = useComposerCheckoutItems(environmentId, { + enabled: open, + query: tab === "checkouts" ? query : "", + }); + + const nextPredictedTab = open + ? predictComposerReferenceTab({ + query, + available, + hasDefaultRepositoryOwner: defaultRepositoryOwners.length > 0, + recents, + preloaded: { + "pull-requests": pullRequestMenu.preloaded, + "linear-issues": linearIssueMenu.preloaded, + "github-issues": githubIssueMenu.preloaded, + "notion-pages": notionMenu.preloaded, + "granola-notes": granolaMenu.preloaded, + checkouts: checkoutMenu.preloaded, + }, + }) + : "pull-requests"; + // Predicted from rows the tabs load, so it settles one render after they arrive. + if (nextPredictedTab !== predictedTab) { + setPredictedTab(nextPredictedTab); + } + + const active = (() => { + switch (tab) { + case "linear-issues": + return { + items: linearIssueMenu.items, + isPending: linearIssueMenu.isPending, + loadingText: "Searching Linear issues...", + emptyState: + linearIssueMenu.error ?? + (query + ? `No Linear issue matches ${query}.` + : "No open Linear issues are assigned to you."), + }; + case "notion-pages": + return { + items: notionMenu.items, + isPending: notionMenu.isPending, + emptyState: notionMenu.error ?? "No Notion pages found. Try searching by title.", + }; + case "granola-notes": + return { + items: granolaMenu.items, + isPending: granolaMenu.isPending, + emptyState: + granolaMenu.error ?? + "No meetings in the last 30 days match. Paste a note link to attach an older one.", + }; + case "slack-messages": + return { + items: slackMenu.items, + isPending: slackMenu.isPending, + loadingText: "Searching Slack...", + emptyState: + slackMenu.error ?? + (query ? `No Slack message matches ${query}.` : "Type a word to search Slack."), + }; + case "discord-messages": + return { + items: discordMenu.items, + isPending: discordMenu.isPending, + loadingText: "Searching Discord...", + emptyState: + discordMenu.error ?? + (query ? `No Discord message matches ${query}.` : "Type a word to search Discord."), + }; + case "github-issues": + return { + items: githubIssueMenu.items, + isPending: githubIssueMenu.isPending, + loadingText: "Searching GitHub issues...", + emptyState: + githubIssueMenu.error ?? + (query + ? `No GitHub issue matches ${query}.` + : "No open GitHub issues in this repository."), + }; + case "repositories": + return { + items: repositoryMenu.items, + isPending: repositoryMenu.isPending, + loadingText: "Listing repositories...", + emptyState: + repositoryMenu.owners.length === 0 + ? "Type an owner like #acme/ to list its repositories, or add repository owners in Settings." + : (repositoryMenu.error ?? + `No repositories in ${describeContextRepositoryOwners(repositoryMenu.owners)} match.`), + }; + case "checkouts": + return { + items: checkoutMenu.items, + isPending: checkoutMenu.isPending, + loadingText: "Looking up checkouts...", + hint: "Enter references. Command-Enter also links it to the thread.", + emptyState: + checkoutMenu.error ?? + (query + ? `No checkout matches ${query}. Paste a path to use another folder.` + : "No local checkouts yet. Paste a path to add one."), + }; + case "pull-requests": + return { + items: pullRequestMenu.items, + isPending: pullRequestMenu.isPending, + emptyState: !pullRequestMenu.available + ? "Pull requests are not available for this project." + : pullRequestMenu.failed + ? "Pull requests could not be read for this project." + : query + ? `No pull request matches ${query}.` + : "No pull requests found in this repository.", + }; + } + })(); + const items = open + ? orderComposerReferenceItems({ + items: active.items, + recents: recents.filter((item) => composerReferenceTabForItem(item) === tab), + query, + limit: REFERENCE_MENU_RESULT_LIMIT, + }) + : EMPTY_REFERENCE_MENU_ITEMS; + + return { + open, + items, + tab, + /** Hidden when only one tab is available. */ + tabs: tabs.length > 1 ? tabs : null, + chooseTab: (id: string) => { + if (!open) return; + setTabChoice({ + rangeStart: trigger.rangeStart, + tab: tabs.find((option) => option.id === id)?.id ?? "pull-requests", + }); + }, + isLoading: open && active.isPending, + emptyState: active.emptyState, + loadingText: "loadingText" in active ? active.loadingText : undefined, + hint: "hint" in active ? active.hint : undefined, + }; +}