diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 74e14453c4ea5..f4a3a9e7c8393 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -58,6 +58,21 @@ import * as McpToolAccessTestkit from "../McpToolAccess.testkit.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; +import { ApprovalToolkit } from "./approval/tools.ts"; +import { AttachmentToolkit } from "./attachment/tools.ts"; +import { DeviceToolkit } from "./device/tools.ts"; +import { EnvironmentToolkit } from "./environment/tools.ts"; +import { HtmlToolkit } from "./html/tools.ts"; +import { IssueLinksToolkit } from "./issueLinks/tools.ts"; +import { MessageDraftsToolkit } from "./messageDrafts/tools.ts"; +import { OrchestratorToolkit } from "./orchestrator/tools.ts"; +import { PreviewToolkit } from "./preview/tools.ts"; +import { PreviewControlsToolkit } from "./previewControls/tools.ts"; +import { ProjectToolkit } from "./project/tools.ts"; +import { PullRequestsToolkit } from "./pullRequests/tools.ts"; +import { ThreadToolkit } from "./thread/tools.ts"; +import { ThreadDeleteToolkit } from "./threadDelete/tools.ts"; +import { WorktreeToolkit } from "./worktree/tools.ts"; const layerOrchestratorToolkit = McpHttpServer.layerOrchestratorToolkit.pipe( Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})), @@ -95,6 +110,29 @@ const layerThreadToolkitWith = ( ); const layerThreadToolkit = layerThreadToolkitWith(layerThreadMoveServices); +// All toolkits share one MCP server, so a duplicate name silently shadows a tool. +it("publishes unique tool names across every toolkit", () => { + const names = [ + ApprovalToolkit, + AttachmentToolkit, + DeviceToolkit, + EnvironmentToolkit, + HtmlToolkit, + IssueLinksToolkit, + MessageDraftsToolkit, + OrchestratorToolkit, + PreviewToolkit, + PreviewControlsToolkit, + ProjectToolkit, + PullRequestsToolkit, + ThreadToolkit, + ThreadDeleteToolkit, + WorktreeToolkit, + ].flatMap((toolkit) => Object.values(toolkit.tools).map((tool) => tool.name)); + expect(names.filter((name, index) => names.indexOf(name) !== index)).toEqual([]); + expect(names).toEqual(expect.arrayContaining(["link_linear_issue", "t3_draft_create"])); +}); + const threadId = ThreadId.make("mcp-core-thread"); const scope: McpInvocationContext.McpInvocationScope = { environmentId: EnvironmentId.make("mcp-core-environment"), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f064fac5b1ddf..11d92640d75b7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4224,6 +4224,57 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps usage warnings and provisioned overage silent", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-overage"), + text: "Continue.", + attachments: [], + }), + ); + for (const rate_limit_info of [ + { status: "allowed_warning" }, + { status: "rejected", overageStatus: "allowed" }, + { status: "rejected", overageStatus: "allowed_warning" }, + { status: "rejected", isUsingOverage: true }, + { status: "rejected", overageInUse: true }, + ]) { + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info, + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000604", + }), + ); + } + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000605", + result: "Done.", + }), + ); + yield* Queue.take(harness.terminalReceipts); + assert.isFalse( + harness.events.some( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("keeps a later future-window event-supplied Claude reset over the embedded reset", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index f7a39809aaa10..68f203cfd7b8d 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1028,6 +1028,31 @@ effectIt.effect("does not mistake a failed state read for a superseded run", () }), ); +effectIt.effect("resumes an imported native session on its first T3 turn", () => + Effect.gen(function* () { + const firstTurnFor = (marked: boolean) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "Continue", + existingClaudeSession: { marked }, + }); + yield* harness.start; + expect(harness.startRootRun).toHaveBeenCalledOnce(); + return harness.startRootRun.mock.calls[0]![0]; + }); + + // Claude creates the session on a first turn without prior native turns, + // which the CLI rejects for an imported session id that already exists. + const imported = yield* firstTurnFor(true); + expect(imported.providerTurnOrdinal).toBe(1); + expect(imported.nativeThreadHasTurns).toBe(true); + + const unmarked = yield* firstTurnFor(false); + expect(unmarked.providerTurnOrdinal).toBe(1); + expect(unmarked.nativeThreadHasTurns).toBe(false); + }), +); + effectIt.effect( "signs out the existing native provider before opening the newly selected provider", () => diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index b9c72305b893a..b0401ceb36038 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1156,6 +1156,55 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports sessions started in a subfolder unless a nested project owns it", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const subfolder = path.join(workspace, "functions", "billing"); + const nestedProject = path.join(workspace, "nested"); + yield* fileSystem.makeDirectory(subfolder, { recursive: true }); + yield* fileSystem.makeDirectory(path.join(nestedProject, "src"), { recursive: true }); + + const writeSession = (cwd: string, sessionId: string, ageMs: number) => + writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-${sessionId}`, `${sessionId}.jsonl`), + contents: `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n`, + mtimeMs: nowMs - ageMs, + }); + yield* writeSession(workspace, "at-root", 1_000); + yield* writeSession(subfolder, "in-subfolder", 2_000); + yield* writeSession(path.join(subfolder, "deleted"), "in-deleted-folder", 3_000); + yield* writeSession(path.join(nestedProject, "src"), "in-nested-project", 4_000); + yield* writeSession(path.dirname(workspace), "above-root", 5_000); + + const sessionIds = (workspaceRoot: string) => + runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot, + importedWorkspaceRoots: [workspace, nestedProject], + }).pipe(Effect.map((threads) => threads.map((thread) => thread.providerSessionId))); + + expect(yield* sessionIds(workspace)).toEqual([ + "at-root", + "in-subfolder", + "in-deleted-folder", + ]); + expect(yield* sessionIds(nestedProject)).toEqual(["in-nested-project"]); + }), + ); + it.effect("keeps the provider instance that owns a custom session home", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx index de274fdd433be..52d91ea37b5a3 100644 --- a/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.permissions.browser.test.tsx @@ -189,7 +189,7 @@ it("removes host actions after revocation while keeping file preview and copying using _setup = setup(); await using screen = await renderMarkdown("[Readme](docs/readme.md)"); await screen.openContextMenu(); - expect(offeredActions()).toEqual(["open", "reveal", "copy-relative", "copy-full"]); + expect(offeredActions()).toEqual(["open", "open-with", "reveal", "copy-relative", "copy-full"]); await setAllowed(false); state.choose.mockResolvedValue("copy-full"); await screen.openContextMenu(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 554143bae7351..1831f928c001b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -38,6 +38,9 @@ import { import { AssetWorkspaceAssetNotFoundError, AuthPreviewOperateScope, + DEFAULT_SERVER_SETTINGS, + EDITORS, + resolveFileOpenTarget, type EditorId, type EnvironmentId, type ServerConfig, @@ -426,10 +429,23 @@ type MarkdownServerConfigFields = ReturnType; /** The server config fields markdown reads. */ function markdownServerConfigFields(config: ServerConfig | null) { + const settings = config?.settings ?? DEFAULT_SERVER_SETTINGS; + const availableEditors = config?.availableEditors ?? NO_MARKDOWN_EDITORS; return { pullRequestsEnabled: config?.environment.capabilities.pullRequests === true, threadPullRequestsEnabled: config?.environment.capabilities.threadPullRequests === true, - availableEditors: config?.availableEditors ?? NO_MARKDOWN_EDITORS, + availableEditors, + // Fork: file links follow Settings → Open in (default target, extension rules, custom apps). + fileOpenSettings: { + fileOpenDefault: settings.fileOpenDefault, + fileOpenRules: settings.fileOpenRules, + }, + openWithEditors: [ + ...EDITORS.filter( + (editor) => editor.id !== "file-manager" && availableEditors.includes(editor.id), + ), + ...settings.customEditors, + ].map(({ id, label }) => ({ id, label })), revealInFileManagerLabel: config?.shellRevealInFileManager === true && config.availableEditors.includes("file-manager") ? config.shellRevealInFileManagerKind === undefined @@ -448,7 +464,18 @@ function markdownServerConfigFieldsEqual( previous.threadPullRequestsEnabled === next.threadPullRequestsEnabled && previous.revealInFileManagerLabel === next.revealInFileManagerLabel && previous.availableEditors.length === next.availableEditors.length && - previous.availableEditors.every((editor, index) => editor === next.availableEditors[index]) + previous.availableEditors.every((editor, index) => editor === next.availableEditors[index]) && + previous.fileOpenSettings.fileOpenDefault === next.fileOpenSettings.fileOpenDefault && + previous.fileOpenSettings.fileOpenRules.length === next.fileOpenSettings.fileOpenRules.length && + previous.fileOpenSettings.fileOpenRules.every((rule, index) => { + const nextRule = next.fileOpenSettings.fileOpenRules[index]; + return rule.extension === nextRule?.extension && rule.target === nextRule.target; + }) && + previous.openWithEditors.length === next.openWithEditors.length && + previous.openWithEditors.every((editor, index) => { + const nextEditor = next.openWithEditors[index]; + return editor.id === nextEditor?.id && editor.label === nextEditor.label; + }) ); } @@ -609,11 +636,17 @@ function useChatMarkdownState({ pullRequestsEnabled, threadPullRequestsEnabled, availableEditors, + fileOpenSettings, + openWithEditors, revealInFileManagerLabel, } = useAtomValue(markdownServerConfigFieldsAtom(environmentId)); const projects = useProjects(); const [preferredEditor] = usePreferredEditor(availableEditors); - const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); + const customEditorLabel = openWithEditors.find((editor) => editor.id === preferredEditor)?.label; + const preferredEditorMenuLabel = + customEditorLabel !== undefined && !EDITORS.some((editor) => editor.id === preferredEditor) + ? `Open in ${customEditorLabel}` + : openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, @@ -856,6 +889,7 @@ function useChatMarkdownState({ fileLinkMeta.workspaceRelativePath ?? (!canPreviewMedia && isHostPath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); + const fileOpenTarget = resolveFileOpenTarget(fileLinkMeta.filePath, fileOpenSettings); return ( openInPreferredEditor( markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), + fileOpenTarget === "t3" ? undefined : fileOpenTarget, + ) + : undefined + } + openExternally={fileOpenTarget !== "t3"} + openWithEditors={openWithEditors} + onOpenWith={ + canUseShellActions + ? async (editor: EditorId) => + openInPreferredEditor( + markdownFileEditorTarget(fileLinkMeta, await resolveHostPath()), + editor, ) : undefined } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1e1f0fda3b04d..8a7085e023ab2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -46,16 +46,42 @@ import { resolveEnvironmentMachineKind, } from "@t3tools/contracts"; import { useLocation, useNavigate } from "@tanstack/react-router"; +import { isGitHubProject } from "@t3tools/client-runtime/state/github-issues"; +import { openGitHubIssuePicker } from "./chat/GitHubIssuePicker"; +import { useThreadGitHubIssueLink, useUnlinkGitHubIssue } from "./chat/GitHubIssueThreadLink"; +import { openNotionPagePicker } from "./chat/NotionPagePicker"; +import { openGranolaNotePicker } from "./chat/GranolaNotePicker"; +import { openSlackMessagePicker } from "./chat/SlackMessagePicker"; +import { openDiscordMessagePicker } from "./chat/DiscordMessagePicker"; +import { openLinearIssuePicker } from "./chat/LinearIssuePicker"; +import { useThreadLinearLink, useUnlinkLinearIssue } from "./chat/LinearThreadLink"; +import { useThreadNotionLink, useUnlinkNotionPage } from "./chat/NotionThreadLink"; +import { useThreadGranolaLink, useUnlinkGranolaNote } from "./chat/GranolaThreadLink"; +import { useThreadSlackLink, useUnlinkSlackThread } from "./chat/SlackThreadLink"; +import { useThreadDiscordLink, useUnlinkDiscordThread } from "./chat/DiscordThreadLink"; +import { openThreadAttachPicker } from "./chat/ThreadAttachPicker"; +import { openPullRequestAttachPicker } from "./chat/PullRequestAttachPicker"; +import { openRepositoryAttachPicker } from "./chat/RepositoryAttachPicker"; +import { openStartFromPicker } from "./chat/StartFromPicker"; +import { dispatchSplitViewAction, useSplitPartner } from "./chat/splitPane"; import { ArrowLeftIcon, + BotIcon, + CalendarClockIcon, ChartNoAxesColumnIcon, CheckIcon, ChevronRightIcon, + Columns2Icon, CornerLeftUpIcon, + DownloadIcon, FileSearchIcon, FolderGit2Icon, FolderIcon, FolderPlusIcon, + GitBranchIcon, + HistoryIcon, + InboxIcon, + LayersIcon, MessageSquareDashedIcon, LinkIcon, MessageSquareIcon, @@ -91,7 +117,10 @@ import { copyPromptLink, promptLinkRepositoryOf, } from "./promptLinks/copyPromptLink"; -import { useClientSettings } from "../hooks/useSettings"; +import { updateClientSettings, useClientSettings } from "../hooks/useSettings"; +import { useAttentionInbox } from "../hooks/useAttentionInbox"; +import { useRestartAgentSession } from "../hooks/useRestartAgentSession"; +import { useRunScheduledTask } from "../hooks/useRunThreadScheduledTask"; import { useStableCallback } from "../hooks/useStableCallback"; import { useTheme } from "../hooks/useTheme"; import { readLocalApi } from "../localApi"; @@ -100,7 +129,12 @@ import { filesystemEnvironment, useFilesystemReadAccess } from "../state/filesys import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { serverEnvironment } from "../state/server"; -import { threadEnvironment } from "../state/threads"; +import { githubIssueEnvironment } from "../state/githubIssues"; +import { linearEnvironment } from "../state/linear"; +import { notionEnvironment } from "../state/notion"; +import { granolaEnvironment } from "../state/granola"; +import { slackEnvironment } from "../state/slack"; +import { discordEnvironment } from "../state/discord"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; @@ -113,7 +147,13 @@ import { useEnvironments, usePrimaryEnvironmentId, } from "../state/environments"; -import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; +import { + readThreadVisibleTurnItems, + useProjects, + useServerConfigs, + useThreadShells, + waitForProject, +} from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { appendBrowsePathSegment, @@ -144,6 +184,8 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams } from "../threadRoutes"; +import { resolveThreadScheduledTasks } from "../threadScheduledTasks"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, @@ -215,6 +257,13 @@ import { scrollCommandPaletteRowIntoView, } from "./CommandPaletteResults"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { DiscordIcon, GitHubIcon, GranolaIcon, LinearIcon, NotionIcon, SlackIcon } from "./Icons"; +import { openImportConversationDialog } from "./ImportConversationDialog"; +import { + ATTENTION_REASON_PRESENTATION, + useProjectTitleByKey, +} from "./sidebar/SidebarAttentionInbox"; +import { openTranscriptExportDialog } from "./TranscriptExportDialog"; import { Checkbox } from "./ui/checkbox"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { openLinkPullRequestDialog } from "./pullRequest/LinkPullRequestDialog"; @@ -314,6 +363,13 @@ export function CommandPalette({ children }: { children: ReactNode }) { dispatch({ _tag: "OpenChangeTheme" }); return; } + if (command === "attentionInbox.open") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + dispatch({ _tag: "OpenAttentionInbox" }); + return; + } if (command === "themeEditor.toggle") { event.preventDefault(); event.stopPropagation(); @@ -362,6 +418,8 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); + } else if (detail.open === "run-scheduled-task") { + dispatch({ _tag: "OpenRunScheduledTask" }); } else if (detail.query !== undefined) { dispatch({ _tag: "OpenSearch", @@ -401,6 +459,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { openCommandSearch={(query) => dispatch({ _tag: "OpenSearch", query, fromFilePicker: true }) } + openFileSearch={(query) => dispatch({ _tag: "OpenFileSearch", query })} clearOpenIntent={clearOpenIntent} /> @@ -419,6 +478,7 @@ function CommandPaletteDialog(props: { readonly initialContext: CommandPaletteReturnContext | undefined; readonly initialFileQuery: string; readonly openCommandSearch: (query: string) => void; + readonly openFileSearch: (query: string) => void; readonly clearOpenIntent: () => void; }) { const composerHandleRef = useComposerHandleContext(); @@ -460,6 +520,7 @@ function CommandPaletteDialog(props: { setOpen={props.setOpen} openChildSearch={props.openChildSearch} initialContext={props.initialContext} + openFileSearch={props.openFileSearch} clearOpenIntent={props.clearOpenIntent} /> )} @@ -482,12 +543,15 @@ function OpenCommandPaletteDialog(props: { context: CommandPaletteReturnContext, ) => void; readonly initialContext: CommandPaletteReturnContext | undefined; + readonly openFileSearch: (query: string) => void; readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const composerHandleRef = useComposerHandleContext(); - const { clearOpenIntent, openIntent, openChildSearch, setOpen } = props; + const attentionEntries = useAttentionInbox(); + const attentionProjectTitleByKey = useProjectTitleByKey(); + const { clearOpenIntent, openIntent, openChildSearch, openFileSearch, setOpen } = props; const initialSearch = props.initialContext ?? { query: openIntent?.kind === "search" ? openIntent.query : "", linkedThreadSearch: openIntent?.kind === "search" ? openIntent : null, @@ -510,6 +574,8 @@ function OpenCommandPaletteDialog(props: { setHighlightedItemValue(null); } const clientSettings = useClientSettings(selectCommandPaletteClientSettings); + const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); + const sidebarShowTabs = useClientSettings((settings) => settings.sidebarShowTabs); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); @@ -527,12 +593,7 @@ function OpenCommandPaletteDialog(props: { const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { reportFailure: false, }); - const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, { - reportFailure: false, - }); - const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { - reportFailure: false, - }); + const restartAgentSession = useRestartAgentSession(); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -540,6 +601,8 @@ function OpenCommandPaletteDialog(props: { const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const paletteRouteTarget = useThreadRouteTarget(); + const splitRouteRef = paletteRouteTarget?.kind === "server" ? paletteRouteTarget.threadRef : null; + const splitViewOpen = useSplitPartner(splitRouteRef) !== null; const projects = useProjects(); const referenceThreadRef = pathname === "/pull-requests" @@ -552,7 +615,61 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); - const activeThreadServerConfig = useServerConfigs().get( + const activeLinkThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null; + const scheduledTaskThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : null; + const canRunScheduledTasks = useAtomValue( + serverEnvironment.runScheduledTaskNow.permissionAtom( + scheduledTaskThreadRef?.environmentId ?? null, + ), + ); + // Subscribed only while the palette is open; the dialog unmounts on close. + const scheduledTasksQuery = useEnvironmentQuery( + scheduledTaskThreadRef !== null && canRunScheduledTasks + ? serverEnvironment.scheduledTasksLive({ + environmentId: scheduledTaskThreadRef.environmentId, + input: {}, + }) + : null, + ); + const runScheduledTask = useRunScheduledTask(); + const activeLinearLink = useThreadLinearLink(activeLinkThreadRef); + const unlinkLinearIssue = useUnlinkLinearIssue(); + const canLinkLinearIssue = useAtomValue( + linearEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const activeSlackLink = useThreadSlackLink(activeLinkThreadRef); + const unlinkSlackThread = useUnlinkSlackThread(); + const canLinkSlack = useAtomValue( + slackEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const activeDiscordLink = useThreadDiscordLink(activeLinkThreadRef); + const unlinkDiscordThread = useUnlinkDiscordThread(); + const canLinkDiscord = useAtomValue( + discordEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const activeNotionLink = useThreadNotionLink(activeLinkThreadRef); + const unlinkNotionPage = useUnlinkNotionPage(); + const canLinkNotion = useAtomValue( + notionEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const activeGranolaLink = useThreadGranolaLink(activeLinkThreadRef); + const unlinkGranolaNote = useUnlinkGranolaNote(); + const canLinkGranola = useAtomValue( + granolaEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const activeGitHubIssueLink = useThreadGitHubIssueLink(activeLinkThreadRef); + const unlinkGitHubIssue = useUnlinkGitHubIssue(); + const canLinkGitHubIssue = useAtomValue( + githubIssueEnvironment.linkThread.permissionAtom(activeLinkThreadRef?.environmentId ?? null), + ); + const serverConfigs = useServerConfigs(); + const activeThreadServerConfig = serverConfigs.get( activeThread?.environmentId ?? ("" as EnvironmentId), ); const activeThreadReferenceCopyTarget = @@ -938,6 +1055,16 @@ function OpenCommandPaletteDialog(props: { } function handleQueryChange(nextQuery: string): void { + // Deleting the `>` that came from the file picker hands the query back to it. + if ( + linkedThreadSearch?.fromFilePicker === true && + currentView === null && + query.startsWith(">") && + !nextQuery.startsWith(">") + ) { + openFileSearch(nextQuery); + return; + } browseNavigation.invalidate(); clearTypedHighlight(); setQuery(nextQuery); @@ -1227,6 +1354,63 @@ function OpenCommandPaletteDialog(props: { }); }, }); + actionItems.push({ + kind: "action", + value: "action:start-thread-from", + searchTerms: [ + "pull request", + "pr", + "branch", + "issue", + "linear", + "checkout", + "start from", + "automation", + "scheduled task", + ], + title: ( + <> + New thread from PR, branch, or issue in{" "} + {activeProjectTitle} + + ), + icon: , + shortcutCommand: "chat.startFrom", + run: async () => { + const started = await startNewThreadFromContext({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }); + if (started) openStartFromPicker(); + }, + }); + } + if ( + [...serverConfigs.values()].some( + (config) => config.environment.capabilities.agentSessionPicker === true, + ) + ) { + actionItems.push({ + kind: "action", + value: "action:import-conversation", + searchTerms: [ + "import", + "conversation", + "claude code", + "codex", + "conductor", + "session", + "resume", + "cli", + ], + title: "Import conversation…", + icon: , + run: async () => { + openImportConversationDialog(); + }, + }); } actionItems.push({ @@ -1372,8 +1556,347 @@ function OpenCommandPaletteDialog(props: { } } - if (activeThread !== null) { - const thread = activeThread; + const runScheduledTaskItems: CommandPaletteActionItem[] = + scheduledTaskThreadRef === null || !scheduledTasksQuery.data + ? [] + : resolveThreadScheduledTasks( + scheduledTasksQuery.data.tasks, + scheduledTaskThreadRef.threadId, + readThreadVisibleTurnItems(scheduledTaskThreadRef), + ).map((task) => ({ + kind: "action", + value: `scheduled-task:${task.id}`, + title: task.title, + searchTerms: [task.title, "scheduled task", "automation", "run now"], + icon: , + run: async () => { + await runScheduledTask(scheduledTaskThreadRef.environmentId, task); + }, + })); + const singleScheduledTask = runScheduledTaskItems.length === 1 ? runScheduledTaskItems[0] : null; + if (singleScheduledTask) { + actionItems.push({ + ...singleScheduledTask, + value: "action:run-scheduled-task", + title: "Run scheduled task now", + description: singleScheduledTask.title, + shortcutCommand: "thread.runScheduledTask", + }); + } else if (runScheduledTaskItems.length > 1) { + actionItems.push({ + kind: "submenu", + value: "action:run-scheduled-task", + searchTerms: ["scheduled task", "automation", "run now"], + title: "Run scheduled task now...", + icon: , + addonIcon: , + shortcutCommand: "thread.runScheduledTask", + groups: [ + { value: "run-scheduled-task", label: "Scheduled tasks", items: runScheduledTaskItems }, + ], + }); + } + + if (activeThread) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + actionItems.push({ + kind: "action", + value: "action:export-transcript", + searchTerms: ["export", "transcript", "markdown", "save", "copy", "conversation"], + title: "Export transcript", + icon: , + run: async () => { + openTranscriptExportDialog(threadRef); + }, + }); + // Fork: the thread's agent fleet in the right panel. + actionItems.push({ + kind: "action", + value: "action:open-agents-panel", + searchTerms: ["agents", "subagents", "fleet", "tasks", "panel"], + title: "Show agents", + icon: , + run: async () => { + useRightPanelStore.getState().open(threadRef, "agents"); + }, + }); + } + + if (activeLinkThreadRef !== null) { + const composerThreadRef = activeLinkThreadRef; + const composerSettings = serverConfigs.get(composerThreadRef.environmentId)?.settings; + actionItems.push({ + kind: "action", + value: "action:attach-thread", + searchTerms: ["thread", "chat", "conversation", "summary", "attach", "context"], + title: "Attach thread", + icon: , + run: async () => { + openThreadAttachPicker(composerThreadRef); + }, + }); + actionItems.push({ + kind: "action", + value: "action:attach-linear-issue", + searchTerms: ["linear", "issue", "ticket", "attach", "context"], + title: "Attach Linear issue", + icon: , + run: async () => { + openLinearIssuePicker(composerThreadRef); + }, + }); + if (composerSettings?.enableNotionIntegration !== false) { + actionItems.push({ + kind: "action", + value: "action:attach-notion-page", + searchTerms: ["notion", "page", "document", "attach", "context"], + title: "Attach Notion page", + icon: , + run: async () => { + openNotionPagePicker(composerThreadRef); + }, + }); + } + if (composerSettings?.enableGranolaIntegration !== false) { + actionItems.push({ + kind: "action", + value: "action:attach-granola-note", + searchTerms: ["granola", "meeting", "notes", "transcript", "call", "attach", "context"], + title: "Attach Granola note", + icon: , + run: async () => { + openGranolaNotePicker(composerThreadRef); + }, + }); + } + if (composerSettings?.enableSlackIntegration !== false) { + actionItems.push({ + kind: "action", + value: "action:attach-slack-message", + searchTerms: ["slack", "message", "thread", "conversation", "attach", "context"], + title: "Attach Slack message", + icon: , + run: async () => { + openSlackMessagePicker(composerThreadRef); + }, + }); + } + if (composerSettings?.enableDiscordIntegration !== false) { + actionItems.push({ + kind: "action", + value: "action:attach-discord-message", + searchTerms: ["discord", "message", "thread", "conversation", "attach", "context"], + title: "Attach Discord message", + icon: , + run: async () => { + openDiscordMessagePicker(composerThreadRef); + }, + }); + } + // Issues are listed from the project's GitHub repository, so only offer them there. + const composerProject = projectByKey.get( + `${composerThreadRef.environmentId}:${currentProjectId ?? ""}`, + ); + if (isGitHubProject(composerProject)) { + actionItems.push({ + kind: "action", + value: "action:attach-github-issue", + searchTerms: ["github", "issue", "ticket", "attach", "context"], + title: "Attach GitHub issue", + icon: , + run: async () => { + openGitHubIssuePicker(composerThreadRef); + }, + }); + if (canLinkGitHubIssue) { + actionItems.push({ + kind: "action", + value: "action:link-github-issue", + searchTerms: ["github", "issue", "ticket", "link", "thread"], + title: activeGitHubIssueLink + ? `Change linked GitHub issue (#${activeGitHubIssueLink.number})` + : "Link GitHub issue", + icon: , + run: async () => { + openGitHubIssuePicker(composerThreadRef, "link"); + }, + }); + } + } + // Unlinking needs no listing, so it stays offered even if the project stops being on GitHub. + if (canLinkGitHubIssue && activeGitHubIssueLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-github-issue", + searchTerms: ["github", "issue", "ticket", "unlink", "remove"], + title: `Unlink GitHub issue #${activeGitHubIssueLink.number}`, + icon: , + run: async () => { + await unlinkGitHubIssue(composerThreadRef); + }, + }); + } + if (canLinkLinearIssue) { + actionItems.push({ + kind: "action", + value: "action:link-linear-issue", + searchTerms: ["linear", "issue", "ticket", "link", "thread"], + title: activeLinearLink + ? `Change linked Linear issue (${activeLinearLink.identifier})` + : "Link Linear issue", + icon: , + run: async () => { + openLinearIssuePicker(composerThreadRef, "link"); + }, + }); + if (activeLinearLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-linear-issue", + searchTerms: ["linear", "issue", "ticket", "unlink", "remove"], + title: `Unlink Linear issue ${activeLinearLink.identifier}`, + icon: , + run: async () => { + await unlinkLinearIssue(composerThreadRef); + }, + }); + } + } + if (canLinkSlack && composerSettings?.enableSlackIntegration === true) { + actionItems.push({ + kind: "action", + value: "action:link-slack-thread", + searchTerms: ["slack", "thread", "conversation", "link"], + title: activeSlackLink + ? `Change linked Slack thread (${activeSlackLink.channelLabel})` + : "Link Slack thread", + icon: , + run: async () => { + openSlackMessagePicker(composerThreadRef, "link"); + }, + }); + } + // Unlinking stays reachable with the integration off: it is the way out of a link. + if (canLinkSlack && activeSlackLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-slack-thread", + searchTerms: ["slack", "thread", "conversation", "unlink", "remove"], + title: `Unlink Slack thread in ${activeSlackLink.channelLabel}`, + icon: , + run: async () => { + await unlinkSlackThread(composerThreadRef); + }, + }); + } + if (canLinkDiscord && composerSettings?.enableDiscordIntegration === true) { + actionItems.push({ + kind: "action", + value: "action:link-discord-thread", + searchTerms: ["discord", "thread", "conversation", "link"], + title: activeDiscordLink + ? `Change linked Discord thread (${activeDiscordLink.channelLabel})` + : "Link Discord thread", + icon: , + run: async () => { + openDiscordMessagePicker(composerThreadRef, "link"); + }, + }); + } + // Unlinking stays reachable with the integration off: it is the way out of a link. + if (canLinkDiscord && activeDiscordLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-discord-thread", + searchTerms: ["discord", "thread", "conversation", "unlink", "remove"], + title: `Unlink Discord thread in ${activeDiscordLink.channelLabel}`, + icon: , + run: async () => { + await unlinkDiscordThread(composerThreadRef); + }, + }); + } + if (canLinkNotion && composerSettings?.enableNotionIntegration === true) { + actionItems.push({ + kind: "action", + value: "action:link-notion-page", + searchTerms: ["notion", "page", "document", "link"], + title: activeNotionLink + ? `Change linked Notion page (${activeNotionLink.title})` + : "Link Notion page", + icon: , + run: async () => { + openNotionPagePicker(composerThreadRef, "link"); + }, + }); + } + if (canLinkNotion && activeNotionLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-notion-page", + searchTerms: ["notion", "page", "document", "unlink", "remove"], + title: `Unlink Notion page ${activeNotionLink.title}`, + icon: , + run: async () => { + await unlinkNotionPage(composerThreadRef); + }, + }); + } + if (canLinkGranola && composerSettings?.enableGranolaIntegration === true) { + actionItems.push({ + kind: "action", + value: "action:link-granola-note", + searchTerms: ["granola", "meeting", "notes", "link"], + title: activeGranolaLink + ? `Change linked Granola note (${activeGranolaLink.title})` + : "Link Granola note", + icon: , + run: async () => { + openGranolaNotePicker(composerThreadRef, "link"); + }, + }); + } + if (canLinkGranola && activeGranolaLink) { + actionItems.push({ + kind: "action", + value: "action:unlink-granola-note", + searchTerms: ["granola", "meeting", "notes", "unlink", "remove"], + title: `Unlink Granola note ${activeGranolaLink.title}`, + icon: , + run: async () => { + await unlinkGranolaNote(composerThreadRef); + }, + }); + } + if ( + serverConfigs.get(composerThreadRef.environmentId)?.environment.capabilities.pullRequests === + true + ) { + actionItems.push({ + kind: "action", + value: "action:attach-pull-request", + searchTerms: ["pull request", "pr", "attach", "context"], + title: "Attach pull request", + icon: , + run: async () => { + openPullRequestAttachPicker(composerThreadRef); + }, + }); + } + actionItems.push({ + kind: "action", + value: "action:attach-repository", + searchTerms: ["repository", "repo", "clone", "context", "ctxclone", "attach"], + title: "Attach repository", + icon: , + run: async () => { + openRepositoryAttachPicker(composerThreadRef); + }, + }); + } + + if (activeThread) { + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); actionItems.push({ kind: "action", value: "action:restart-agent-session", @@ -1381,30 +1904,7 @@ function OpenCommandPaletteDialog(props: { title: "Restart agent session", icon: , run: async () => { - const { environmentId } = thread; - if (thread.runtime !== null) { - const stopped = await stopThreadSession({ - environmentId, - input: { threadId: thread.id }, - }); - if (stopped._tag === "Failure") throw squashAtomCommandFailure(stopped); - } - toastManager.add({ - type: "success", - title: "Agent session will restart", - description: "Your next message starts a fresh session.", - }); - const project = projectByKey.get(`${environmentId}:${thread.projectId}`); - if (!project) return; - const refreshed = await refreshProviders({ - environmentId, - input: { - instanceId: thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId, - cwd: thread.worktreePath ?? project.workspaceRoot, - fresh: true, - }, - }); - if (refreshed._tag === "Failure") throw squashAtomCommandFailure(refreshed); + await restartAgentSession(threadRef); }, }); } @@ -1528,18 +2028,135 @@ function OpenCommandPaletteDialog(props: { groups: [{ value: "themes", label: "Change theme", items: [] }], }); return; + case "run-scheduled-task": + pushPaletteView({ + addonIcon: , + groups: [{ value: "run-scheduled-task", label: "Scheduled tasks", items: [] }], + }); + return; + case "attention-inbox": + pushPaletteView({ + addonIcon: , + groups: [{ value: "attention-inbox", label: "Needs attention", items: [] }], + }); + return; } }); + const runScheduledTaskItemCount = runScheduledTaskItems.length; useLayoutEffect(() => { if (openIntent === null) return; // A new-thread-in intent waits until the project items exist. if (openIntent.kind === "new-thread-in" && projectThreadItemCount === 0) return; + // The task list arrives over a subscription the dialog opens on mount. + if (openIntent.kind === "run-scheduled-task" && runScheduledTaskItemCount === 0) return; // oxlint-disable-next-line react/set-state-in-effect -- applying the intent dispatches to the parent reducer and can navigate, neither of which may run during render. applyOpenIntent(openIntent); - }, [openIntent, projectThreadItemCount]); + }, [openIntent, projectThreadItemCount, runScheduledTaskItemCount]); actionItems.push(themeEditorItem); + if (splitRouteRef) { + actionItems.push({ + kind: "action", + value: "action:split-view", + searchTerms: ["split", "side by side", "compare", "two", "pane", "tabs", "columns"], + title: splitViewOpen ? "Close split view" : "Open split view", + icon: , + shortcutCommand: "splitView.toggle", + run: async () => { + dispatchSplitViewAction("toggle"); + }, + }); + if (splitViewOpen) { + actionItems.push({ + kind: "action", + value: "action:split-view-focus-other", + searchTerms: ["split", "pane", "focus", "other", "switch"], + title: "Focus other split pane", + icon: , + shortcutCommand: "splitView.focusOther", + run: async () => { + dispatchSplitViewAction("focus-other"); + }, + }); + } + } + + if (!legacySidebarEnabled) { + actionItems.push({ + kind: "action", + value: "action:sidebar-tabs", + searchTerms: ["tabs", "sidebar", "chat tabs", "group", "show", "hide"], + title: sidebarShowTabs ? "Hide tabs in sidebar" : "Show tabs in sidebar", + icon: , + shortcutCommand: "sidebar.toggleTabs", + run: async () => { + await updateClientSettings({ sidebarShowTabs: !sidebarShowTabs }); + }, + }); + } + + const attentionInboxItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:attention-inbox", + searchTerms: [ + "needs attention", + "inbox", + "attention", + "approval", + "input", + "question", + "failed", + "completed", + "unread", + "review", + ], + title: "Needs attention", + ...(attentionEntries.length > 0 + ? { + titleTrailingContent: ( + + {attentionEntries.length} + + ), + } + : {}), + icon: , + addonIcon: , + shortcutCommand: "attentionInbox.open", + groups: [ + { + value: "attention-inbox", + label: "Needs attention", + items: attentionEntries.map((entry) => { + const { label, Icon, iconClassName } = ATTENTION_REASON_PRESENTATION[entry.reason]; + const projectTitle = attentionProjectTitleByKey.get( + `${entry.thread.environmentId}:${entry.thread.projectId}`, + ); + return { + kind: "action" as const, + value: `attention:${entry.key}`, + searchTerms: [entry.thread.title, label, projectTitle ?? ""], + title: entry.thread.title, + description: projectTitle ? `${label} · ${projectTitle}` : label, + timestamp: formatRelativeTimeLabel(entry.at), + icon: , + run: async () => { + // The entry's own route, not its tab group's last-opened tab. + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(entry.thread.environmentId, entry.thread.id), + ), + }); + }, + }; + }), + }, + ], + }; + actionItems.push(attentionInboxItem); + if ( environments.some( (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, @@ -1650,9 +2267,19 @@ function OpenCommandPaletteDialog(props: { ) : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups - : currentView?.groups[0]?.value === "appearance" - ? changeAppearanceItem.groups - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === "attention-inbox" + ? attentionInboxItem.groups + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : currentView?.groups[0]?.value === "run-scheduled-task" + ? [ + { + value: "run-scheduled-task", + label: "Scheduled tasks", + items: runScheduledTaskItems, + }, + ] + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, diff --git a/apps/web/src/components/CommandPaletteThreadItems.tsx b/apps/web/src/components/CommandPaletteThreadItems.tsx index e2732f5419082..a832cce537fd5 100644 --- a/apps/web/src/components/CommandPaletteThreadItems.tsx +++ b/apps/web/src/components/CommandPaletteThreadItems.tsx @@ -59,7 +59,7 @@ function useThreadSearchWithContentMatches(query: string) { const threadSearch = useThreadSearch(environmentIds, query); const contentMatchByKey = new Map( threadSearch.matches.flatMap((match) => - match.source === "user" || match.source === "assistant" + match.source === "user" || match.source === "assistant" || match.source === "group_name" ? [[threadSearchMatchKey(match), match] as const] : [], ), @@ -130,7 +130,8 @@ export function useCommandPaletteThreadItems(input: { threadId: thread.id, }), ); - return match && (match.source === "user" || match.source === "assistant") + return match && + (match.source === "user" || match.source === "assistant" || match.source === "group_name") ? { source: match.source, snippet: match.snippet, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index edc83c41b9f1d..760a454ea8fa9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1983,11 +1983,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {agentIndicator} - {/* Left of the status slot, so hover swapping the label for the - wider row controls does not slide the pin. */} - {pinIndicator} + {/* Status at rest, hover actions once they fit. The pin is the + trailing item, outside that swap, so it cannot slide sideways. */} {sortable?.isDragging ? ( - dragDestination + <> + {dragDestination} + {pinIndicator} + ) : ( ) : null} + {pinIndicator} )} {props.sweepAction !== null ? dragDestination : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index a3d3c47149286..e5311eef5d887 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,5 +1,7 @@ import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; +import { resolveComposerSnapshot, type ComposerSnapshot } from "./composerPendingSnapshot"; +import { useCaptureThreadTabContext } from "./ThreadTabs"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; @@ -230,6 +232,10 @@ import { } from "../composerContextPresentation"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useLinkClickHandler } from "~/browser/useOpenLink"; +import { + clearComposerSnapshotRecords, + importComposerSnapshotRecord, +} from "~/lib/composerSnapshotImport"; import { collectInlineContextIds, stripInlineContextReferences, @@ -325,6 +331,7 @@ import { isComposerReferenceItem as isReferenceMenuItem, } from "./composerReferenceMenu.logic"; import { useComposerReferenceMenu } from "./useComposerReferenceMenu"; +import { useSplitPaneFocus } from "./splitPane"; import { ComposerFooterModeControls, ComposerFooterPrimaryActions, @@ -1667,29 +1674,14 @@ function useComposerImageGroups( }; } -function withThreadTabMenuItems( - items: readonly ComposerCommandItem[], - group: ThreadTabGroup | null, - activeThreadId: ThreadId | null, - query: string, -): ComposerCommandItem[] { - if (group === null) return [...items]; - const tabQuery = query.trim().toLowerCase(); - const tabThreadIds = new Set(group.tabs.map((tab) => tab.threadId)); - const tabs: ComposerCommandItem[] = group.tabs - .filter((tab) => tab.threadId !== activeThreadId && tab.title.toLowerCase().includes(tabQuery)) - .map((tab) => ({ - id: `thread-tab:${tab.threadId}`, - type: "thread-tab", - threadId: tab.threadId, - label: tab.title, - description: "Chat tab", - })); - return [ - ...tabs, - ...items.filter((item) => item.type !== "thread" || !tabThreadIds.has(item.thread.threadId)), - ]; -} +type ComposerPathTab = "files" | "chats" | "agents" | "models"; +/** Fork: the `@` menu's tabs. Agents shows only when the thread has agents. */ +const PATH_MENU_TABS: ReadonlyArray<{ id: ComposerPathTab; label: string }> = [ + { id: "files", label: "Files" }, + { id: "chats", label: "Chats" }, + { id: "agents", label: "Agents" }, + { id: "models", label: "Models" }, +]; const NO_PROVIDER_SKILLS: ServerProvider["skills"] = []; const NO_PROVIDER_SLASH_COMMANDS: ServerProvider["slashCommands"] = []; @@ -2006,6 +1998,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) supportsAttachmentUploads, maxFileAttachmentBytes, }); + const attachmentUploadPendingReason = supportsAttachmentUploads + ? attachmentUploadBlockReason({ + imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), + uploadsByImageId, + environmentId, + }) + : null; const attachmentBlockReason = (questionAttachmentTarget && !supportsQuestionAttachments && @@ -2018,11 +2017,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? needsReattachFileCount === 1 ? "Attach the interrupted file again or remove it" : "Attach the interrupted files again or remove them" - : attachmentUploadBlockReason({ - imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), - uploadsByImageId, - environmentId, - }) + : attachmentUploadPendingReason : null); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); @@ -2346,6 +2341,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const stashInFlightRef = useRef>(new Set()); const pendingImageCompressionsRef = useRef>(new Map()); const pendingThreadImportsRef = useRef>(new Map()); + const unfocusedSplitPane = useSplitPaneFocus() === "unfocused"; const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); useLayoutEffect(() => { isRevertingCheckpointRef.current = isRevertingCheckpoint; @@ -2460,6 +2456,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const attachSlackMessage = useAttachSlackMessage(); const attachDiscordMessage = useAttachDiscordMessage(); const attachGitHubIssue = useAttachGitHubIssue(); + const captureThreadTabContext = useCaptureThreadTabContext(environmentId, activeThreadId); const { items: composerMenuItemsFromTrigger, isLoading: isTriggerMenuLoading, @@ -2480,21 +2477,38 @@ 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( - composerMenuItemsFromTrigger, - threadTabGroup, - activeThreadId, - composerTrigger.query, - ) - : composerMenuItemsFromTrigger; const isPathTrigger = composerTrigger?.kind === "path"; const pathTriggerQuery = isPathTrigger ? composerTrigger.query : ""; + // Fork: the `@` menu splits files from chats. Files lists workspace paths only; Chats lists + // this thread's other chat tabs, then other threads by recency. + const fileMenuItems = isPathTrigger + ? composerMenuItemsFromTrigger.filter((item) => item.type === "path") + : []; + const chatMenuItems = ((): ComposerCommandItem[] => { + if (!isPathTrigger) return []; + const tabQuery = pathTriggerQuery.trim().toLowerCase(); + const tabs = threadTabGroup?.tabs ?? []; + const tabThreadIds = new Set(tabs.map((tab) => tab.threadId)); + return [ + ...tabs + .filter( + (tab) => tab.threadId !== activeThreadId && tab.title.toLowerCase().includes(tabQuery), + ) + .map((tab) => ({ + id: `thread-tab:${tab.threadId}`, + type: "thread-tab" as const, + threadId: tab.threadId, + label: tab.title, + description: "Chat tab", + })), + ...composerMenuItemsFromTrigger.filter( + (item) => item.type === "thread" && !tabThreadIds.has(item.thread.threadId), + ), + ]; + })(); // A draft has no agents, and its thread does not exist on the server yet. const agentMenu = useComposerAgentItems( routeThreadRef, @@ -2521,13 +2535,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), instanceFilter: modelInstanceFilter, }); + // A tab picked by hand holds until this `@` token closes; otherwise the query picks it: + // Agents for a handle, Models for a model name, Chats when only chats match, else Files. const [pathTabChoice, setPathTabChoice] = useState<{ rangeStart: number; - tab: "files" | "agents" | "models"; + tab: ComposerPathTab; } | null>(null); const [isModelChipPickerOpen, setIsModelChipPickerOpen] = useState(false); const [attachMenuAnchor, setAttachMenuAnchor] = useState(null); - const pathTab = + const pathTab: ComposerPathTab = isPathTrigger && pathTabChoice?.rangeStart === composerTrigger.rangeStart && (pathTabChoice.tab !== "agents" || agentMenu.available) @@ -2536,16 +2552,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "agents" : modelMenu.prefersModels ? "models" - : "files"; + : pathTriggerQuery.trim().length > 0 && + chatMenuItems.length > 0 && + !isTriggerMenuLoading && + fileMenuItems.length === 0 + ? "chats" + : "files"; + const isComposerMenuLoading = referenceMenu.open + ? referenceMenu.isLoading + : isTriggerMenuLoading && (!isPathTrigger || pathTab === "files"); const composerMenuItems = referenceMenu.open ? [...referenceMenu.items] : !isPathTrigger - ? pathMenuBase + ? composerMenuItemsFromTrigger : pathTab === "models" ? [...modelMenu.items] : pathTab === "agents" ? [...agentMenu.items] - : pathMenuBase; + : pathTab === "chats" + ? chatMenuItems + : fileMenuItems; const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -2972,7 +2998,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? existing.record.id : undefined, }) - : existing?.kind === "thread" + : existing && existing.kind !== "image" && existing.kind !== "file" ? existing.record : existing ? (uploadedContextRecordFromDraft(existing) ?? undefined) @@ -3034,6 +3060,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) rewritten.set(record.contextId, record.contextId); break; } + case "checkout": { + // A checkout is a path on its own machine; it means nothing to another server. + const threadId = routeThreadRef?.threadId; + if (!threadId || record.environmentId !== environmentId) break; + rewritten.set( + record.contextId, + importComposerSnapshotRecord(threadId, record, conflicts), + ); + break; + } + case "repository": + case "thread-tab": + case "linear-issue": + case "github-issue": + case "slack-thread": + case "discord-thread": + case "notion-page": + case "granola-note": + case "subagent": + case "prompt-link": + case "github-ref": + case "unread-link": + case "model": { + const threadId = routeThreadRef?.threadId; + if (!threadId) break; + rewritten.set( + record.contextId, + importComposerSnapshotRecord(threadId, record, conflicts), + ); + break; + } case "image": case "file": { if (skippedDependentAttachmentIds.has(record.contextId)) break; @@ -3424,7 +3481,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) convertTypedObjectLinkRef.current?.(previousPrompt, nextPrompt, expandedCursor); }; const onPromptChangeStable = useStableCallback(onPromptChange); - + const pendingComposerSnapshotRef = useRef(null); const applyPromptReplacement = ( rangeStart: number, rangeEnd: number, @@ -3475,6 +3532,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) insertedSpaces: options.citationComment.insertedSpaces, }); } + pendingComposerSnapshotRef.current = { + value: next.text, + cursor: nextCursor, + expandedCursor: nextExpandedCursor, + contextIds: isLiteralPendingAnswer ? [] : collectInlineContextIds(next.text), + }; promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -3552,9 +3615,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) contextIds: string[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); - if (editorSnapshot) { - return editorSnapshot; + const pendingSnapshot = pendingComposerSnapshotRef.current; + if ( + editorSnapshot?.value === promptRef.current || + pendingSnapshot?.value !== promptRef.current + ) { + pendingComposerSnapshotRef.current = null; } + const snapshot = resolveComposerSnapshot( + promptRef.current, + editorSnapshot, + pendingComposerSnapshotRef.current, + ); + if (snapshot) return snapshot; return { value: promptRef.current, cursor: composerCursor, @@ -3675,6 +3748,35 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "thread-tab") { + if (!captureThreadTabContext) return; + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + }); + if (!applied) return; + setComposerHighlightedItemId(null); + // The summary is fetched after the query text leaves, so the chip lands at the caret + // wherever it is by then. + captureThreadTabContext.capture(item.threadId, item.label).then( + (reference) => { + const current = readComposerSnapshot(); + const edit = inlineContextReferenceReplacement( + current.value, + { start: current.expandedCursor, end: current.expandedCursor }, + [reference], + ); + applyPromptReplacement(edit.start, edit.end, edit.text); + }, + (cause: unknown) => { + toastManager.add({ + type: "error", + title: "Could not include that chat", + description: cause instanceof Error ? cause.message : undefined, + }); + }, + ); + return; + } if (item.type === "slash-command") { if (item.command === "model") { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { @@ -3961,6 +4063,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return onSaveMessageDraft(); }; const saveMessageDraftStable = useStableCallback(saveMessageDraft); + const saveCitationAsDraftStable = useStableCallback(() => { + void saveMessageDraft(); + }); const loadMessageDraft = (draft: MessageDraft) => { const rewrittenContextIds = importContextRecords( messageDraftComposerRecords(draft), @@ -4109,15 +4214,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }; const openSendContextMenuStable = useStableCallback(openSendContextMenu); + // A surface beside the composer asked to send what it just wrote here. Wait out a connect, + // attachments it just handed over that are still uploading, and the git status of a draft that + // just opened (until it lands, a non-git folder reads as a repo and asks for a base branch), + // then send as Enter would; anything else that stops it leaves the draft in place to send by hand. const submitRequested = useComposerSubmitRequestStore((state) => state.pending.has(composerDraftTargetKey), ); + const attachmentsUploading = attachmentUploadPendingReason !== null; useEffect(() => { - if (!submitRequested || isConnecting || isSendBusy || checkoutKindPending) return; + if ( + !submitRequested || + isConnecting || + isSendBusy || + attachmentsUploading || + checkoutKindPending + ) { + return; + } const intent = useComposerSubmitRequestStore.getState().take(composerDraftTargetKey); if (intent === "save-draft") void saveMessageDraftStable(); else if (intent === "send") submitComposerStable(); }, [ + attachmentsUploading, checkoutKindPending, composerDraftTargetKey, isConnecting, @@ -4139,6 +4258,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); }; const handleSubmitMessageStable = useStableCallback(handleSubmitMessage); + const citationSendDispatchMode = resolveComposerDispatchMode({ + running: phase === "running", + alternateModifier: false, + activeTurnDefault: followUpBehavior, + canSteer: !isCompacting, + }); const submitCitationAndSend = async (citationText: string): Promise => { if ( noProviderAvailable || @@ -4146,15 +4271,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return false; } - return onSendCitation( - citationText, - resolveComposerDispatchMode({ - running: phase === "running", - alternateModifier: false, - activeTurnDefault: followUpBehavior, - canSteer: !isCompacting, - }), - ); + return onSendCitation(citationText, citationSendDispatchMode); }; const submitCitationAndSendStable = useStableCallback(submitCitationAndSend); // A question took the editor while a citation comment was @@ -4785,6 +4902,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) clearComposerDraftPromptAndImages(stashTarget); clearComposerDraftTerminalContexts(stashTarget); setComposerDraftThreadContexts(stashTarget, []); + clearComposerSnapshotRecords(routeThreadRef.threadId); for (const comment of composerReviewComments) { removeComposerDraftReviewComment(stashTarget, comment.id); } @@ -5405,6 +5523,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } useEffect(() => { + // Both split panes mount a composer; only the focused one answers window-level keys. + if (unfocusedSplitPane) return; const handler = (event: globalThis.KeyboardEvent) => { const command = resolveShortcutCommand(event, keybindings, { context: { @@ -5413,6 +5533,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) modelPickerOpen: isComposerModelPickerOpen, }, }); + if (command === "composer.saveDraft") { + // Unclaimed where there is no thread to keep a draft on, so the key keeps its default. + if (!canSaveMessageDraft || isCommandPaletteOpen()) return; + event.preventDefault(); + event.stopPropagation(); + void saveMessageDraftStable(); + return; + } if (command !== "composer.stash") return; // Always claim the shortcut so the browser save dialog never opens, // even when the composer is in a state that can't stash. @@ -5441,6 +5569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) stashCurrentPromptStable, isRevertingCheckpoint, terminalOpen, + unfocusedSplitPane, + canSaveMessageDraft, + saveMessageDraftStable, ]); const addComposerAttachments = async ( @@ -7096,7 +7227,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? pathTriggerQuery ? `No model matches ${pathTriggerQuery}.` : "No models available." - : composerMenuEmptyState + : isPathTrigger && pathTab === "chats" + ? pathTriggerQuery + ? `No chat matches ${pathTriggerQuery}.` + : "No other chats yet." + : composerMenuEmptyState } activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlightedStable} @@ -7126,18 +7261,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {...(isPathTrigger ? { tabs: { - options: [ - { id: "files", label: "Files" }, - ...(agentMenu.available ? [{ id: "agents", label: "Agents" }] : []), - { id: "models", label: "Models" }, - ], + options: agentMenu.available + ? PATH_MENU_TABS + : PATH_MENU_TABS.filter((tab) => tab.id !== "agents"), activeId: pathTab, - onSelect: (id: string) => + onSelect: (id: string) => { + const tab = PATH_MENU_TABS.find((option) => option.id === id); + if (!tab) return; setPathTabChoice({ rangeStart: composerTrigger.rangeStart, - tab: - id === "models" ? "models" : id === "agents" ? "agents" : "files", - }), + tab: tab.id, + }); + }, }, ...(pathTab === "models" ? { @@ -7236,6 +7371,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} onCitationSubmitAndSend={submitCitationAndSendStable} + onCitationSubmitAndSaveDraft={ + canSaveMessageDraft ? saveCitationAsDraftStable : undefined + } + citationSendQueues={citationSendDispatchMode === "queue"} citationDestinations={ onSendCitationElsewhere && activeThreadId ? { diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index 1e9af29614861..5190cda8400ca 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -440,13 +440,15 @@ export function ComposerAttachmentFileList({ {file.name} - {needsReattach - ? canReattachFile - ? "Attach again" - : "Remove to send" - : upload?.status === "uploading" - ? formatAttachmentUploadProgress(upload.progress) - : formatAttachmentSize(file.sizeBytes)} + {file.restoringBytes + ? "Restoring…" + : needsReattach + ? canReattachFile + ? "Attach again" + : "Remove to send" + : upload?.status === "uploading" + ? formatAttachmentUploadProgress(upload.progress) + : formatAttachmentSize(file.sizeBytes)} {!needsReattach && upload?.status === "failed" ? ( diff --git a/apps/web/src/components/chat/MarkdownFileLink.tsx b/apps/web/src/components/chat/MarkdownFileLink.tsx index 9c4328ccfbcd4..cedf068fdf593 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -4,7 +4,7 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EditorId, EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { memo, type MouseEvent as ReactMouseEvent } from "react"; import { readLocalApi } from "../../localApi"; @@ -12,8 +12,8 @@ import { cn } from "../../lib/utils"; import { shouldOpenMarkdownFileLinkInEditor } from "../../markdown-links"; import type { markdownFileCopyPaths } from "../../markdownFileHostPath"; import { ContextChip } from "../ContextChip"; +import { ContextPreviewHover } from "../contextPreviewRegistry"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { FileTagChipContent } from "./FileTagChip"; import { hasMarkdownFilePrimaryAction, @@ -21,7 +21,6 @@ import { shouldUseMarkdownFileBrowserPrimaryAction, } from "./MarkdownFileLink.logic"; import { reportMarkdownActionFailure } from "./markdownActionFailure"; -import { WorkspaceEntryTooltip } from "./WorkspaceEntryIcon"; interface MarkdownFileLinkProps { environmentId: EnvironmentId | null; @@ -36,6 +35,10 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen?: (() => Promise>) | undefined; + /** Fork: the file's Open in target is an editor, so a plain click runs `onOpen`. */ + openExternally?: boolean; + openWithEditors?: readonly { readonly id: EditorId; readonly label: string }[]; + onOpenWith?: ((editor: EditorId) => Promise>) | undefined; onOpenInPanel: (panelPath: string, line: number | undefined) => void; resolveCopyPaths: () => Promise>; openInEditorMenuLabel: string; @@ -61,6 +64,9 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ threadRef, onOpen, onOpenInPanel, + openExternally, + openWithEditors, + onOpenWith, openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, @@ -106,7 +112,20 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }; + const handleOpenInViewer = () => { + if (threadRef && panelPath) { + onOpenInPanel(panelPath, line); + return; + } + onOpenMedia?.(); + }; + const canOpenInViewer = (threadRef !== undefined && Boolean(panelPath)) || Boolean(onOpenMedia); + const handleOpenInFilePreview = () => { + if (openExternally && onOpen) { + handleOpenInEditor(); + return; + } if (threadRef && panelPath) { onOpenInPanel(panelPath, line); return; @@ -249,7 +268,22 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ const clicked = await api.contextMenu.show( [ ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), + ...(openExternally && canOpenInViewer + ? ([{ id: "open-in-viewer", label: "Open in T3 file viewer" }] as const) + : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), + ...(onOpenWith && openWithEditors?.length + ? [ + { + id: "open-with", + label: "Open with", + children: openWithEditors.map((editor) => ({ + id: `editor:${editor.id}`, + label: editor.label, + })), + }, + ] + : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), @@ -263,6 +297,22 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ onOpenMedia?.(); return; } + if (clicked === "open-in-viewer") { + handleOpenInViewer(); + return; + } + if (clicked?.startsWith("editor:") && onOpenWith) { + const result = await onOpenWith(clicked.slice("editor:".length) as EditorId); + if (result._tag === "Failure") + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file", + description: targetPath, + }), + ); + return; + } if (clicked === "open") { handleOpenInEditor(); return; @@ -322,64 +372,60 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ }); return ( - - } - className={MARKDOWN_FILE_LINK_CLASS_NAME} - data-markdown-copy={copyMarkdown} - onClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { - handleOpenInEditor(); - return; - } - if (useBrowserPrimaryAction) { - handleOpenInBrowser(); - return; - } - handleOpenInFilePreview(); - }} - onContextMenu={handleContextMenu} - > - - - ) : ( - } - aria-label={`File options for ${label}`} - aria-haspopup="menu" - className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} - data-markdown-copy={copyMarkdown} - onClick={handleContextMenu} - onContextMenu={handleContextMenu} - > - - - ) - } - /> - -
- -
-
-
+ } + className={MARKDOWN_FILE_LINK_CLASS_NAME} + data-markdown-copy={copyMarkdown} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } + if (useBrowserPrimaryAction && !openExternally) { + handleOpenInBrowser(); + return; + } + handleOpenInFilePreview(); + }} + onContextMenu={handleContextMenu} + > + + + ) : ( + } + aria-label={`File options for ${label}`} + aria-haspopup="menu" + className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} + data-markdown-copy={copyMarkdown} + onClick={handleContextMenu} + onContextMenu={handleContextMenu} + > + + + ) + } + /> ); }, areMarkdownFileLinkPropsEqual); @@ -400,6 +446,9 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.openExternally === next.openExternally && + previous.openWithEditors === next.openWithEditors && + previous.onOpenWith === next.onOpenWith && previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 407ed1e439d1c..d635fb90bbe5d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -498,6 +498,32 @@ describe("MessagesTimeline messages", () => { expect((globalThis as { __t3Xss?: number }).__t3Xss).toBeUndefined(); }); + it("forks a user message into a new tab only where tabs exist", async () => { + const onForkFromMessage = vi.fn(); + { + await using screen = await renderTimeline([message("Try this")], { onForkFromMessage }); + await click(screen.button("Fork into new tab")); + expect(onForkFromMessage).toHaveBeenCalledWith(MessageId.make("message-1")); + } + await using screen = await renderTimeline([message("Try this")]); + expect(screen.buttons("Fork into new tab")).toHaveLength(0); + }); + + it("attaches a subagent to its parent chat from the Subagent of divider", async () => { + const onAttach = vi.fn(); + await using screen = await renderTimeline([message("Work")], { + parentThreadLink: { + threadId: "thread-parent" as never, + title: "Parent", + handle: "researcher", + onAttach, + }, + }); + expect(screen.text()).toContain("@researcher"); + await click(screen.button("Attach to parent chat")); + expect(onAttach).toHaveBeenCalledOnce(); + }); + it("renders sanitized raw HTML in assistant messages", async () => { await using screen = await renderTimeline([ message( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8382c32248445..7a5210c8d5d39 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -43,7 +43,14 @@ import { import { type TimelineEntry } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; -import { ArrowRightIcon, BotIcon, Minimize2Icon } from "lucide-react"; +import { ArrowRightIcon, AtSignIcon, BotIcon, FolderGit2Icon, Minimize2Icon } from "lucide-react"; +import { + isWorkspaceChangeNotice, + WORKSPACE_CHANGED_NOTICE_TITLE, +} from "@t3tools/shared/workspaceChangeNotice"; +import { isAbsolutePath } from "@t3tools/shared/path"; +import { MiddleTruncate } from "../ui/middle-truncate"; +import ChatMarkdown from "../ChatMarkdown"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -73,7 +80,9 @@ import { import { type ThreadFindMatch } from "./threadFind"; import { useThreadFindNavigation } from "./useThreadFindNavigation"; import type { ThreadFindPositionReader } from "./threadFind"; -import { TooltipScrollDismissArea } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipScrollDismissArea, TooltipTrigger } from "../ui/tooltip"; +import { Button } from "../ui/button"; +import { AgentHandle } from "./AgentHandle"; import { WorktreeSetupCard } from "./WorktreeSetupCard"; import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useClientSettings } from "~/hooks/useSettings"; @@ -208,6 +217,10 @@ interface MessagesTimelineProps { parentThreadLink?: { readonly threadId: ThreadId; readonly title: string; + /** Fork: this subagent thread's `@handle` among its parent's agents. */ + readonly handle?: string | null; + /** Fork: attaches this agent to the parent's chat and opens the parent. */ + readonly onAttach?: (() => void) | null; } | null; /** Set on a workflow coordinator's thread: its script message renders as this opening. */ workflowOpening?: WorkflowOpening | null; @@ -223,6 +236,8 @@ interface MessagesTimelineProps { threadStartCheckpointReady?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; + /** Opens a new tab seeded from a user message; omitted where the chat cannot have tabs. */ + onForkFromMessage?: (messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; onSendAppMessage?: (text: string) => Promise; onRunShellCommand?: (command: string) => void; @@ -323,6 +338,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ threadStartCheckpointReady = false, availableRevertTurnCounts, onRevertToTurnCount, + onForkFromMessage, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, onSendAppMessage, onRunShellCommand, @@ -809,6 +825,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ { ...position, offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, + rowHeight: row.getBoundingClientRect().height, + viewportWidth: element.clientWidth, scrollOffset: element.scrollTop, atEnd: isAtEnd, disclosures: { @@ -904,6 +922,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ resolvedTheme, workspaceRoot, skills, + onForkFromMessage: onForkFromMessage ?? null, runs, activeThreadEnvironmentId, onRevertToTurnCount, @@ -967,6 +986,31 @@ const ConversationTimeline = memo(function ConversationTimeline({ icon={BotIcon} actionLabel="Open parent thread" onAction={() => onOpenThread(parentThreadLink.threadId)} + trailing={ + (parentThreadLink.handle ?? null) === null ? null : ( + + + {parentThreadLink.onAttach ? ( + + + } + > + + + Attach to parent chat + + ) : null} + + ) + } /> @@ -1191,6 +1235,21 @@ function getItemType(item: MessagesTimelineRow) { } const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { + const workspaceChangeNotice = + row.kind === "work" && row.groupedEntries.length === 1 + ? row.groupedEntries[0]!.structuredPayload + : undefined; + if (workspaceChangeNotice && isWorkspaceChangeNotice(workspaceChangeNotice)) { + return ( +
+ } + /> +
+ ); + } const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; const isSubagentGroup = row.kind === "event" && row.projectedItem.item.type === "subagent"; const isWorkLogRow = @@ -1273,6 +1332,29 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +const CONTINUING_IN_PREFIX = "Continuing in "; + +/** The worktree a moved thread continues in, as the same file chip agent messages use. */ +function WorkspaceChangeNoticeDetail(props: { + notice: Extract; +}) { + const ctx = use(TimelineRowCtx); + const { message } = props.notice; + const path = message.startsWith(CONTINUING_IN_PREFIX) + ? message.slice(CONTINUING_IN_PREFIX.length) + : ""; + // Moving back to the project checkout names no path. + if (!isAbsolutePath(path)) return ; + return ( + )`} + cwd={ctx.markdownCwd} + threadRef={ctx.threadRef ?? undefined} + className="w-auto text-2xs text-current" + /> + ); +} + function WorktreeSetupTimelineRow({ row, }: { diff --git a/apps/web/src/components/chat/TimelineToolActivityIcon.tsx b/apps/web/src/components/chat/TimelineToolActivityIcon.tsx index 8d85ed2eefd67..697de35732d3c 100644 --- a/apps/web/src/components/chat/TimelineToolActivityIcon.tsx +++ b/apps/web/src/components/chat/TimelineToolActivityIcon.tsx @@ -1,6 +1,7 @@ import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { type ToolActivityIcon } from "@t3tools/contracts"; -import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; +import { toolActivityBrandMark, toolActivityFaviconUrl } from "@t3tools/shared/favicon"; +import { GranolaIcon, LinearIcon } from "~/components/Icons"; import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; import { use, useState } from "react"; import { T3Wordmark } from "../T3Wordmark"; @@ -85,6 +86,11 @@ export function ToolActivityIconView(props: { if (!props.icon) { return ; } + const brandMark = toolActivityBrandMark(props.icon); + if (brandMark) { + const BrandIcon = brandMark === "linear" ? LinearIcon : GranolaIcon; + return ; + } if (props.icon._tag === "website") { const src = toolActivityFaviconUrl(props.icon, resolvedTheme, 32); return src ? ( diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 86d95c80e61c1..afc0c2d28dff2 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -15,7 +15,7 @@ import { isVideoAttachment, } from "../../types"; import ChatMarkdown from "../ChatMarkdown"; -import { DownloadIcon, EyeIcon, Redo2Icon, Undo2Icon } from "lucide-react"; +import { DownloadIcon, EyeIcon, GitForkIcon, Redo2Icon, Undo2Icon } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; @@ -433,6 +433,9 @@ function UserTimelineRow({ row }: { row: Extract )} + {ctx.onForkFromMessage && ( + + )} {resolvedContext.text && ( void; +}) { + return ( + + onFork(messageId)} + aria-label="Fork into new tab" + /> + } + > + + + Fork into new tab + + ); +} + const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 38cd8cfbbdf9e..d1f983b4a292b 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -17,11 +17,11 @@ import { resolveViewedImageAsset, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; +import { turnItemHasDetail } from "@t3tools/client-runtime/work-log/item-detail"; import { - turnItemHasDetail, - turnItemNeedsDetailFetch, -} from "@t3tools/client-runtime/work-log/item-detail"; -import { claudeSkillInvocation } from "@t3tools/shared/toolActivity"; + toolEntryShowsPathBreadcrumbs, + toolPathTargets, +} from "@t3tools/client-runtime/work-log/tool-paths"; import { memo, use, useState, type KeyboardEvent, type ReactNode } from "react"; import { workEntryDisplayIndicatesToolFailure, @@ -46,13 +46,16 @@ import { threadReadLabelPrefix, threadReadTargetId, threadReadTargetTitle, - toolGroupAction, workEntryDisplayLabel, - workEntryReadOutput, } from "./MessagesTimeline.logic"; import { cn } from "~/lib/utils"; -import { formatUpcomingTimestamp } from "../../timestampFormat"; -import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; +import { formatSecondsTimestamp, formatUpcomingTimestamp } from "../../timestampFormat"; +import { PreviewCard, PreviewCardPopup, PreviewCardTrigger } from "../ui/preview-card"; +import { useCursorAnchor } from "./CursorPreviewCard"; +import { workEntryHasToolCallPreview } from "../../lib/toolCallPreview"; +import { ToolPathText } from "./ToolPathText"; +import { ThoughtPreviewContent } from "./ThoughtPreviewContent"; +import { ToolCallDetails, toolCallDetailOutput } from "./ToolCallDetail"; import { SubagentNotificationLink } from "./V2LifecycleRow"; import { TimelineRowCtx, @@ -118,9 +121,6 @@ export function ThreadReadLabel({ ); } -const toolCallExpandedBodyClassName = - "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-(length:--font-size-code,var(--text-2xs)) leading-relaxed select-text"; - const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); function PullRequestNotificationLabel({ @@ -157,32 +157,68 @@ function PullRequestNotificationLabel({ ); } -export function ReasoningTraceContent({ entries }: { entries: ReadonlyArray }) { - const ctx = use(TimelineRowCtx); +function useReasoningIsStreaming() { const { isWorking, latestRunId } = use(TimelineRowActivityCtx); - const isStreaming = (entry: TimelineWorkEntry) => + return (entry: TimelineWorkEntry) => isWorking && entry.runId === latestRunId && entry.toolLifecycleStatus === "inProgress"; +} + +function ThoughtMarkdown({ + entry, + streaming, + className, +}: { + entry: TimelineWorkEntry; + streaming: boolean; + className: string; +}) { + const ctx = use(TimelineRowCtx); + return ( + + ); +} + +export function ReasoningTraceContent({ entries }: { entries: ReadonlyArray }) { + const isStreaming = useReasoningIsStreaming(); return ( {entries.map((entry) => ( - ))} ); } +/** Mounted only while the row's hover card is open. */ +function ThoughtPreview({ entry }: { entry: TimelineWorkEntry }) { + const { timestampFormat } = use(TimelineRowCtx); + const streaming = useReasoningIsStreaming()(entry); + return ( + + + + ); +} + type WorkEntryRowProps = { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; @@ -236,7 +272,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { const [expanded, setExpanded] = useState( () => groupView?.state.expandedEntries.has(workEntry.id) ?? false, ); + const [previewOpen, setPreviewOpen] = useState(false); + const previewAnchor = useCursorAnchor(); const toggleExpanded = () => { + setPreviewOpen(false); const next = !expanded; if (groupView) { groupView.onToggleEntry(!next); @@ -279,11 +318,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { } trailing={ - + // Matches the timestamp's size and centres on the first label line, since wrapLabel top-aligns the row. + + + } + > + View diagnostics + + + } > {!warning ? ( @@ -336,17 +389,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { ? getQuestionAnswerPreview(workEntry.questionAnswer) : null; const viewedImagePath = workEntryViewedImagePath(workEntry); - const payload = workEntry.structuredPayload; - const skill = - payload?.type === "dynamic_tool" - ? claudeSkillInvocation(payload.toolName, payload.input) - : undefined; - const plainOutput = - toolGroupAction(workEntry) === "read" - ? workEntryReadOutput(workEntry, workspaceRoot) - : skill - ? (skill.args ?? null) - : undefined; + const { plainOutput, plainOutputFetches } = toolCallDetailOutput(workEntry, workspaceRoot); const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -375,10 +418,6 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { viewedImage ? viewedImagePath : null, ) : null; - const plainOutputFetches = - plainOutput !== undefined && - workEntry.projectedItem !== undefined && - turnItemNeedsDetailFetch(workEntry.projectedItem.item); const canExpandProjectedItem = plainOutput !== undefined ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches) @@ -410,6 +449,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { : workLogEntryIsToolLike(workEntry) ? "text-secondary-label" : "text-foreground/80"; + const showsPaths = toolEntryShowsPathBreadcrumbs(workEntry); const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget); const pullRequestLabel = pullRequestNotificationLabel( workEntry, @@ -438,7 +478,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { } : {}; - return ( + const row = (

- + {isReasoning && !expanded ? ( + ) : showsPaths ? ( + ) : ( previewText )} @@ -578,35 +632,62 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { plainOutputFetches || (workEntry.projectedItem && plainOutput === undefined)) ? ( - {workEntry.projectedItem && plainOutput === undefined ? ( - - ) : ( - <> - {expandedBody ? ( -

{expandedBody}
- ) : null} - {plainOutputFetches && workEntry.projectedItem ? ( - - ) : null} - - )} + ) : null}
); + + const hasThoughtPreview = isReasoning && Boolean(workEntry.detail?.trim()); + // The card stays mounted while expanded so toggling never remounts the row. + if (!hasThoughtPreview && !workEntryHasToolCallPreview(workEntry, canExpandProjectedItem)) + return row; + return ( + { + if (open) previewAnchor.pin(); + setPreviewOpen(!expanded && open); + }} + > + + + {hasThoughtPreview ? ( + + ) : ( + + + + } + /> + )} + + + ); } export function QuestionAnswerLabel({ diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index cf94f65081a71..87483fbed9cec 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -7,6 +7,11 @@ import { } from "@t3tools/client-runtime/work-log/user-input"; import { type ToolActivityIcon } from "@t3tools/contracts"; import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation"; +import { + toolEntryShowsPathBreadcrumbs, + toolPathTargets, +} from "@t3tools/client-runtime/work-log/tool-paths"; +import { ToolPathText } from "./ToolPathText"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; import { observeResize } from "~/lib/observeResize"; import { observeVisibleAnimation } from "../../lib/visibleAnimation"; @@ -509,12 +514,15 @@ export function LiveWorkEntryTimelineRow({ const threadLabel = threadReadLabel(label, threadTarget); const failed = workEntryDisplayIndicatesToolFailure(row.entry); + const showsPaths = toolEntryShowsPathBreadcrumbs(row.entry); const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; + // Not a + ); } diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index fdfb833ecc6aa..cb14fc2a65f00 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -33,6 +33,7 @@ export interface TimelineRowSharedState { runs: ReadonlyArray; activeThreadEnvironmentId: EnvironmentId; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; + onForkFromMessage: ((messageId: MessageId) => void) | null; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onSendAppMessage: ((text: string) => Promise) | undefined; onAppFullscreenChange: (rowId: string, fullscreen: boolean) => void; diff --git a/apps/web/src/components/chat/useTimelinePositionRestore.ts b/apps/web/src/components/chat/useTimelinePositionRestore.ts index 700a2a41af06b..62b21d1ee2f5f 100644 --- a/apps/web/src/components/chat/useTimelinePositionRestore.ts +++ b/apps/web/src/components/chat/useTimelinePositionRestore.ts @@ -2,7 +2,10 @@ import { useLayoutEffect, useRef } from "react"; import type { LegendListRef } from "@legendapp/list/react"; import type { AssistantCitationRequest } from "./AssistantCitationSource"; import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; -import type { RememberedTimelinePosition } from "./timelineScrollAnchoring"; +import { + type RememberedTimelinePosition, + resolveRestoredTimelineRowOffset, +} from "./timelineScrollAnchoring"; /** * Scrolls a newly shown thread back to where the user left it, or to its end, @@ -115,13 +118,19 @@ export function useTimelinePositionRestore({ const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); const element = list.getScrollableNode(); if (!row || !element) return; + const rowBounds = row.getBoundingClientRect(); + const offsetWithinRow = resolveRestoredTimelineRowOffset( + position, + rowBounds.height, + element.clientWidth, + ); const offset = Math.max( 0, Math.min( element.scrollTop + - row.getBoundingClientRect().top - + rowBounds.top - element.getBoundingClientRect().top + - position.offsetWithinRow, + offsetWithinRow, element.scrollHeight - element.clientHeight, ), ); diff --git a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx index 1c7323f3bb0f9..baf68ee2e040a 100644 --- a/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActionsMenu.tsx @@ -21,7 +21,7 @@ import { } from "lucide-react"; import { shortcutLabelForCommand } from "~/keybindings"; -import { readLocalApi } from "~/localApi"; +import { useLinkClickHandler } from "~/browser/useOpenLink"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { Button } from "../ui/button"; import { @@ -52,6 +52,7 @@ export function PullRequestActionsMenu({ reference, pullRequestKey, detail, + threadRef, linkThreadRef, onThreadPickerOpenChange, refreshing, @@ -91,6 +92,7 @@ export function PullRequestActionsMenu({ reference: PullRequestRef; pullRequestKey: string; detail: Pick; + threadRef: ScopedThreadRef | null; linkThreadRef: ScopedThreadRef | null; onThreadPickerOpenChange: (open: boolean) => void; refreshing: boolean; @@ -129,6 +131,7 @@ export function PullRequestActionsMenu({ const keybindings = useAtomValue(primaryServerKeybindingsAtom); const inlineConfirm = useInlineActionConfirm(pullRequestKey); const actionPending = pendingAction !== null; + const onLinkClick = useLinkClickHandler(threadRef); return ( @@ -290,7 +293,7 @@ export function PullRequestActionsMenu({ ) : null} ) : null} - void readLocalApi()?.shell.openExternal(detail.url)}> + onLinkClick(event, detail.url)}> {openOnHostText} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx b/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx index 2441cf3ce5e83..5e1cee103e47e 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailCrumbs.tsx @@ -1,7 +1,9 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react"; import type { MouseEvent as ReactMouseEvent } from "react"; +import { useLinkClickHandler, useOpenLink } from "~/browser/useOpenLink"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "../ui/button"; @@ -16,12 +18,17 @@ interface CrumbsPullRequest { readonly title: string; } -const openNumberContextMenu = (event: ReactMouseEvent, detail: CrumbsPullRequest): void => { +const openNumberContextMenu = ( + event: ReactMouseEvent, + detail: CrumbsPullRequest, + openLink: (url: string) => Promise, +): void => { event.preventDefault(); event.stopPropagation(); void showPullRequestLinkContextMenu({ url: detail.url, openLabel: openOnHostLabel(sourceControlClients.get(detail.provider)), + openLink, position: { x: event.clientX, y: event.clientY }, }); }; @@ -56,11 +63,15 @@ function NumberLink({ toneClassName, openOnHostText, focusable, + onLinkClick, + openLink, }: { detail: CrumbsPullRequest; toneClassName: string; openOnHostText: string; focusable: boolean; + onLinkClick: ReturnType; + openLink: (url: string) => Promise; }) { return ( @@ -69,11 +80,11 @@ function NumberLink({