diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b0b8e510de5bb..5a1bdf2235025 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -100,6 +100,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; +import { copyPullRequestUrls } from "../lib/copyPullRequestUrls"; import { copyThreadReference } from "../lib/copyThreadReference"; import { queueWorkspaceFocusFromPalette, @@ -111,6 +112,7 @@ import { promptLinkRepositoryOf, } from "./promptLinks/copyPromptLink"; import { useClientSettings } from "../hooks/useSettings"; +import { useNowMinute } from "../hooks/useNowMinute"; import { useStableCallback } from "../hooks/useStableCallback"; import { readLocalApi } from "../localApi"; import { configuredHostedAppUrl } from "../hostedPairing"; @@ -138,6 +140,7 @@ import { } from "../state/environments"; import { useAnyEnvironmentHiding, + useDelegatedUnderParent, useHiddenThreadKeys, useEnvironmentThreadRefs, useProjects, @@ -229,7 +232,12 @@ import { type SearchOverlayMode, } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { isSidebarHiddenThread } from "./Sidebar.logic"; +import { + collectVisiblePullRequestUrls, + filterSidebarV2VisibleThreads, + isSidebarHiddenThread, + isSidebarIdleThread, +} from "./Sidebar.logic"; import { CommandPaletteContent, type PendingInputSelection } from "./CommandPaletteContent"; import { selectCommandPaletteClientSettings, @@ -494,9 +502,11 @@ function OpenCommandPaletteDialog(props: { ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null; const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(referenceThreadRef); - const activeThreadServerConfig = useServerConfigs().get( + const serverConfigs = useServerConfigs(); + const activeThreadServerConfig = serverConfigs.get( activeThread?.environmentId ?? ("" as EnvironmentId), ); + const nowMinute = useNowMinute(); const canShareActiveThreadWithGuest = useAtomValue( canShareWithGuestAtom(activeThread?.environmentId ?? ("" as EnvironmentId)), ); @@ -518,6 +528,15 @@ function OpenCommandPaletteDialog(props: { }; // Side chats and subagents open inside their parent, so opening a project never lands on one. const threads = useVisibleThreadShells().filter((thread) => !isSidebarHiddenThread(thread)); + const delegatedUnderParent = useDelegatedUnderParent(); + const idlePullRequestUrls = collectVisiblePullRequestUrls( + filterSidebarV2VisibleThreads(threads, null, delegatedUnderParent).filter((thread) => + isSidebarIdleThread(thread, { + now: nowMinute, + configLoaded: serverConfigs.has(thread.environmentId), + }), + ), + ); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providersByEnvironment = useEnvironmentProviders(); const { changeThemeItem, changeAppearanceItem, themeEditorItem } = useCommandPaletteThemeItems(); @@ -1447,6 +1466,18 @@ function OpenCommandPaletteDialog(props: { }); } + if (idlePullRequestUrls.length > 0) { + actionItems.push({ + kind: "action", + value: "action:copy-idle-pr-links", + searchTerms: ["copy", "pull request", "pr", "links", "active", "idle", "pinned", "clipboard"], + title: "Copy PR links from idle threads", + description: `${idlePullRequestUrls.length} link${idlePullRequestUrls.length === 1 ? "" : "s"}`, + icon: , + run: () => copyPullRequestUrls(idlePullRequestUrls), + }); + } + if ( activeThread !== null && threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported" diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b89aa1621a0fa..84c89d81ea0f8 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -7,6 +7,9 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { shouldCreateNewThreadInCurrentProject, + buildBulkCopyPullRequestLinksContextMenuItem, + collectVisiblePullRequestUrls, + isSidebarIdleThread, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, hasUnseenCompletion, @@ -44,7 +47,14 @@ import { } from "@t3tools/client-runtime/state/thread-sort"; import { resolveProjectExpanded } from "../uiStateStore"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; -import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, + type ThreadPullRequestLink, +} from "@t3tools/contracts"; import { DEFAULT_INTERACTION_MODE, @@ -61,6 +71,104 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +const pullRequestLink = ( + number: number, + source: ThreadPullRequestLink["source"] = "created", +): ThreadPullRequestLink => ({ + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source, + linkedAt: "2026-01-01T00:00:00.000Z", + snapshot: null, + stack: null, +}); + +describe("collectVisiblePullRequestUrls", () => { + it("dedupes URLs in first-seen order and skips dismissed stack members", () => { + expect( + collectVisiblePullRequestUrls([ + { pullRequests: [pullRequestLink(1), pullRequestLink(2, "stack-dismissed")] }, + { pullRequests: [pullRequestLink(2), pullRequestLink(1), pullRequestLink(3)] }, + { pullRequests: undefined }, + ]), + ).toEqual([ + "https://github.com/pingdotgg/t3code/pull/1", + "https://github.com/pingdotgg/t3code/pull/2", + "https://github.com/pingdotgg/t3code/pull/3", + ]); + }); +}); + +describe("buildBulkCopyPullRequestLinksContextMenuItem", () => { + it("hides the action when nothing is linked", () => { + expect(buildBulkCopyPullRequestLinksContextMenuItem({ urlCount: 0 })).toBeNull(); + }); + + it("labels the action with the unique link count", () => { + expect(buildBulkCopyPullRequestLinksContextMenuItem({ urlCount: 3 })).toEqual({ + id: "copy-pr-links", + label: "Copy PR links (3)", + }); + }); +}); + +describe("isSidebarIdleThread", () => { + const now = "2026-01-01T12:00:00.000Z"; + + it("keeps unsettled finished threads including pinned, and drops settled, snoozed, and Working", () => { + // Finished run → idle. Runtime "idle" is still Working. + const idle = makeThreadFixture({ + id: ThreadId.make("idle"), + settledOverride: null, + pinnedAt: null, + runtime: null, + latestRun: null, + }); + expect(isSidebarIdleThread(idle, { now })).toBe(true); + expect( + isSidebarIdleThread( + makeThreadFixture({ + id: ThreadId.make("pinned-idle"), + settledOverride: null, + pinnedAt: "2026-01-01T00:00:00.000Z", + runtime: null, + latestRun: null, + }), + { now }, + ), + ).toBe(true); + expect(isSidebarIdleThread({ ...idle, settledOverride: "settled" }, { now })).toBe(false); + expect( + isSidebarIdleThread( + { + ...idle, + snoozedUntil: "2026-01-01T18:00:00.000Z", + snoozedAt: "2026-01-01T00:00:00.000Z", + }, + { now }, + ), + ).toBe(false); + expect( + isSidebarIdleThread( + { + ...idle, + runtime: { + status: "idle", + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: now, + }, + }, + { now }, + ), + ).toBe(false); + }); +}); + describe("deleteSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = AsyncResult.success(undefined); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7c96c89d30289..d64ca37acb77e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,9 +1,13 @@ import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; +import { isThreadWorking } from "@t3tools/client-runtime/state/thread-inbox"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; -import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import { + threadPullRequestSearchTerms, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; import * as React from "react"; import { isAtomCommandInterrupted, @@ -23,6 +27,7 @@ import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, + type ThreadPullRequestLink, } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; @@ -203,6 +208,31 @@ export function resolveSidebarThreadSection(input: { return "active"; } +/** + * Idle threads: unsettled, unsnoozed, and not in Working. Pinned idle threads count. + */ +export function isSidebarIdleThread( + thread: Pick< + SidebarThreadSummary, + | "settledOverride" + | "snoozedAt" + | "snoozedUntil" + | "hasActionableProposedPlan" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "pendingSubagentRequest" + | "interactionMode" + | "latestRun" + | "runtime" + >, + input: { readonly now: string; readonly configLoaded?: boolean }, +): boolean { + const configLoaded = input.configLoaded ?? true; + if (configLoaded && thread.settledOverride === "settled") return false; + if (configLoaded && effectiveSnoozed(thread, { now: input.now })) return false; + return !isThreadWorking(thread); +} + const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = @@ -657,6 +687,34 @@ export function buildBulkUnpinContextMenuItem(input: { return { id: "unpin", label: `Unpin (${input.pinnedCount})` }; } +/** Unique visible PR URLs across threads, first-seen order. */ +export function collectVisiblePullRequestUrls( + threads: ReadonlyArray<{ + readonly pullRequests?: ReadonlyArray | undefined; + }>, +): string[] { + const urls: string[] = []; + const seen = new Set(); + for (const thread of threads) { + for (const link of visibleThreadPullRequests(thread.pullRequests ?? [])) { + if (seen.has(link.url)) continue; + seen.add(link.url); + urls.push(link.url); + } + } + return urls; +} + +export function buildBulkCopyPullRequestLinksContextMenuItem(input: { + urlCount: number; +}): ContextMenuItem<"copy-pr-links"> | null { + if (input.urlCount === 0) return null; + return { + id: "copy-pr-links", + label: `Copy PR links (${input.urlCount})`, + }; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 4c539afa78fc3..472c17748a0fe 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -131,6 +131,7 @@ import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { copyPullRequestUrls } from "../lib/copyPullRequestUrls"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { @@ -178,8 +179,10 @@ import { filterSidebarV2VisibleThreads, groupSidebarThreadsByProject, sidebarFolderExpansionKeys, + buildBulkCopyPullRequestLinksContextMenuItem, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, + collectVisiblePullRequestUrls, deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, @@ -2323,6 +2326,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const unpinMenuItem = buildBulkUnpinContextMenuItem({ pinnedCount: pinnedSelectedThreads.length, }); + const pullRequestUrls = collectVisiblePullRequestUrls(selectedThreads); + const copyPullRequestLinksMenuItem = buildBulkCopyPullRequestLinksContextMenuItem({ + urlCount: pullRequestUrls.length, + }); // Presets sync across environments, so the first selected thread's stand for all. const menuOpenedAt = new Date(); const snoozePresets = resolveSnoozePresets( @@ -2374,6 +2381,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ] : []), + ...(copyPullRequestLinksMenuItem ? [copyPullRequestLinksMenuItem] : []), { id: "mark-unread", label: `Mark unread (${count})` }, { id: "delete", @@ -2386,6 +2394,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ), ); if (clicked._tag === "Failure" || clicked.value === null) return; + if (clicked.value === "copy-pr-links") { + await copyPullRequestUrls(pullRequestUrls); + return; + } const actionTargets = clicked.value === "unpin" ? pinnedSelectedThreads diff --git a/apps/web/src/lib/copyPullRequestUrls.ts b/apps/web/src/lib/copyPullRequestUrls.ts new file mode 100644 index 0000000000000..a3f4a66460c69 --- /dev/null +++ b/apps/web/src/lib/copyPullRequestUrls.ts @@ -0,0 +1,24 @@ +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; + +export async function copyPullRequestUrls(urls: readonly string[]): Promise { + if (urls.length === 0) return; + try { + const didCopy = await writeTextToClipboard(urls.join("\n"), "pull request links"); + if (!didCopy) return; + toastManager.add({ + type: "success", + title: urls.length === 1 ? "PR link copied" : "PR links copied", + description: urls.length === 1 ? urls[0] : `${urls.length} links`, + }); + } catch (error) { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy PR links", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } +}