| author | |
| committer | |
| log | 0e7346ccd9427c06161e222319e718e6bd70887f |
| tree | 41b0e5c84e8024ec724c523386b229cfa7b89eba |
| parent | b821fa04f1b9fe9c6fdb8d19ab7e4197b94c934b |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 177 insertions(+), 40 deletions(-)
apps/web/src/components/ChatView.tsx+12| ... | @@ -4803,6 +4803,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4803,6 +4803,15 @@ export default function ChatView(props: ChatViewProps) { |
| 4803 | ) | 4803 | ) |
| 4804 | return; | 4804 | return; |
| 4805 | if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { | 4805 | if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) { |
| 4806 | // Server-created setup terminals exist before this client's drawer mounts. | ||
| 4807 | // Reuse them before allocating a shell from the still-empty UI state. | ||
| 4808 | const existingTerminalId = | ||
| 4809 | runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ?? | ||
| 4810 | activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id)); | ||
| 4811 | if (existingTerminalId !== undefined) { | ||
| 4812 | storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true }); | ||
| 4813 | return; | ||
| 4814 | } | ||
| 4806 | if (!activeThreadId || !activeProject) { | 4815 | if (!activeThreadId || !activeProject) { |
| 4807 | return; | 4816 | return; |
| 4808 | } | 4817 | } |
| ... | @@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) { |
| 4829 | } | 4838 | } |
| 4830 | setTerminalOpen(nextOpen); | 4839 | setTerminalOpen(nextOpen); |
| 4831 | }, [ | 4840 | }, [ |
| 4841 | activeServerOrderedTerminalIds, | ||
| 4832 | activeProject, | 4842 | activeProject, |
| 4833 | activeThreadId, | 4843 | activeThreadId, |
| 4834 | activeThreadRef, | 4844 | activeThreadRef, |
| ... | @@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) { |
| 4837 | environmentId, | 4847 | environmentId, |
| 4838 | gitCwd, | 4848 | gitCwd, |
| 4839 | openTerminal, | 4849 | openTerminal, |
| 4850 | panelTerminalIds, | ||
| 4851 | runningTerminalIds, | ||
| 4840 | hasTerminalWriteAccess, | 4852 | hasTerminalWriteAccess, |
| 4841 | setTerminalOpen, | 4853 | setTerminalOpen, |
| 4842 | storeEnsureTerminal, | 4854 | storeEnsureTerminal, |
apps/web/src/components/LegacySidebar.tsx+6-23| ... | @@ -9,15 +9,13 @@ import { | ... | @@ -9,15 +9,13 @@ import { |
| 9 | Globe2Icon, | 9 | Globe2Icon, |
| 10 | SearchIcon, | 10 | SearchIcon, |
| 11 | SquarePenIcon, | 11 | SquarePenIcon, |
| 12 | TerminalIcon, | ||
| 13 | TriangleAlertIcon, | 12 | TriangleAlertIcon, |
| 14 | } from "lucide-react"; | 13 | } from "lucide-react"; |
| 15 | import { | 14 | import { |
| 16 | ChangeRequestStatusIcon, | 15 | ChangeRequestStatusIcon, |
| 17 | prStatusIndicator, | 16 | prStatusIndicator, |
| 18 | PrStatusTooltipContent, | 17 | PrStatusTooltipContent, |
| 19 | terminalStatusFromRunningIds, | 18 | ThreadTerminalStatusButton, |
| 20 | synchronizeTerminalPulse, | ||
| 21 | ThreadStatusLabel, | 19 | ThreadStatusLabel, |
| 22 | ThreadWorktreeIndicator, | 20 | ThreadWorktreeIndicator, |
| 23 | useLinkedThreadPullRequest, | 21 | useLinkedThreadPullRequest, |
| ... | @@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 506 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) | 504 | ? resolveThreadCurrentPullRequestLink(thread.pullRequests) |
| 507 | : null; | 505 | : null; |
| 508 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | 506 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); |
| 509 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | ||
| 510 | const isConfirmingArchive = | 507 | const isConfirmingArchive = |
| 511 | canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; | 508 | canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| 512 | const threadMetaClassName = isConfirmingArchive | 509 | const threadMetaClassName = isConfirmingArchive |
| ... | @@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 849 | </Tooltip> | 846 | </Tooltip> |
| 850 | )} | 847 | )} |
| 851 | <ThreadWorktreeIndicator thread={thread} /> | 848 | <ThreadWorktreeIndicator thread={thread} /> |
| 852 | {terminalStatus && ( | 849 | <ThreadTerminalStatusButton |
| 853 | <Tooltip> | 850 | threadRef={threadRef} |
| 854 | <TooltipTrigger | 851 | terminalIds={runningTerminalIds} |
| 855 | render={ | 852 | onThreadActivate={navigateToThread} |
| 856 | <span | 853 | /> |
| 857 | role="img" | ||
| 858 | aria-label={terminalStatus.label} | ||
| 859 | className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`} | ||
| 860 | /> | ||
| 861 | } | ||
| 862 | > | ||
| 863 | <TerminalIcon | ||
| 864 | className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`} | ||
| 865 | onAnimationStart={synchronizeTerminalPulse} | ||
| 866 | /> | ||
| 867 | </TooltipTrigger> | ||
| 868 | <TooltipPopup side="top">{terminalStatus.label}</TooltipPopup> | ||
| 869 | </Tooltip> | ||
| 870 | )} | ||
| 871 | <div | 854 | <div |
| 872 | className={`flex min-w-12 justify-end ${ | 855 | className={`flex min-w-12 justify-end ${ |
| 873 | isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20" | 856 | isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20" |
apps/web/src/components/Sidebar.tsx+11-15| ... | @@ -1,7 +1,10 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; | 1 | import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts"; |
| 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; | 2 | import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard"; |
| 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; | 3 | import { CollapsibleSectionHeader } from "./ui/collapsible-section-header"; |
| 4 | import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators"; | 4 | import { |
| 5 | setThreadChangeRequestSnapshot, | ||
| 6 | ThreadTerminalStatusButton, | ||
| 7 | } from "./ThreadStatusIndicators"; | ||
| 5 | import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; | 8 | import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost"; |
| 6 | import { | 9 | import { |
| 7 | dropThreadContext, | 10 | dropThreadContext, |
| ... | @@ -248,7 +251,6 @@ import { | ... | @@ -248,7 +251,6 @@ import { |
| 248 | terminalStatusFromRunningIds, | 251 | terminalStatusFromRunningIds, |
| 249 | threadChangeRequestSnapshotsAtom, | 252 | threadChangeRequestSnapshotsAtom, |
| 250 | type ThreadChangeRequestSnapshot, | 253 | type ThreadChangeRequestSnapshot, |
| 251 | synchronizeTerminalPulse, | ||
| 252 | type TerminalStatusIndicator, | 254 | type TerminalStatusIndicator, |
| 253 | useLinkedThreadPullRequest, | 255 | useLinkedThreadPullRequest, |
| 254 | ThreadSearchPullRequestNumber, | 256 | ThreadSearchPullRequestNumber, |
| ... | @@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1725 | onOpenPullRequest={handlePrClick} | 1727 | onOpenPullRequest={handlePrClick} |
| 1726 | /> | 1728 | /> |
| 1727 | ) : null; | 1729 | ) : null; |
| 1728 | const terminalStatusIcon = terminalStatus ? ( | 1730 | const terminalStatusIcon = ( |
| 1729 | <span | 1731 | <ThreadTerminalStatusButton |
| 1730 | role="img" | 1732 | threadRef={threadRef} |
| 1731 | aria-label={terminalProcessLabel(terminalProcessCount)} | 1733 | terminalIds={runningTerminalIds} |
| 1732 | data-testid={`sidebar-terminal-status-${thread.id}`} | 1734 | onThreadActivate={onThreadActivate} |
| 1733 | className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)} | 1735 | /> |
| 1734 | > | 1736 | ); |
| 1735 | <TerminalIcon | ||
| 1736 | className={cn("size-3.5", terminalStatus.pulse && "motion-safe:animate-status-pulse")} | ||
| 1737 | onAnimationStart={synchronizeTerminalPulse} | ||
| 1738 | /> | ||
| 1739 | </span> | ||
| 1740 | ) : null; | ||
| 1741 | // Same pen the new-thread draft rows lead with, so both kinds of unsent | 1737 | // Same pen the new-thread draft rows lead with, so both kinds of unsent |
| 1742 | // work read the same way in the list. | 1738 | // work read the same way in the list. |
| 1743 | const draftIndicator = hasUnsentDraft ? ( | 1739 | const draftIndicator = hasUnsentDraft ? ( |
apps/web/src/components/ThreadStatusIndicators.tsx+51| ... | @@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull | ... | @@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull |
| 9 | import { | 9 | import { |
| 10 | resolveEnvironmentMachineKind, | 10 | resolveEnvironmentMachineKind, |
| 11 | type EnvironmentId, | 11 | type EnvironmentId, |
| 12 | type ScopedThreadRef, | ||
| 12 | type ThreadLinkedPullRequest, | 13 | type ThreadLinkedPullRequest, |
| 13 | type ThreadPullRequestLink, | 14 | type ThreadPullRequestLink, |
| 14 | type VcsStatusResult, | 15 | type VcsStatusResult, |
| ... | @@ -35,6 +36,7 @@ import { | ... | @@ -35,6 +36,7 @@ import { |
| 35 | type ComponentProps, | 36 | type ComponentProps, |
| 36 | } from "react"; | 37 | } from "react"; |
| 37 | import { cn } from "../lib/utils"; | 38 | import { cn } from "../lib/utils"; |
| 39 | import { revealThreadTerminal } from "../lib/revealThreadTerminal"; | ||
| 38 | 40 | ||
| 39 | import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; | 41 | import { parseChangeRequestUrl } from "../lib/openPullRequestLink"; |
| 40 | import { useEnvironmentQuery } from "../state/query"; | 42 | import { useEnvironmentQuery } from "../state/query"; |
| ... | @@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) { | ... | @@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) { |
| 786 | } | 788 | } |
| 787 | } | 789 | } |
| 788 | 790 | ||
| 791 | export function ThreadTerminalStatusButton({ | ||
| 792 | threadRef, | ||
| 793 | terminalIds, | ||
| 794 | onThreadActivate, | ||
| 795 | }: { | ||
| 796 | threadRef: ScopedThreadRef; | ||
| 797 | terminalIds: readonly string[]; | ||
| 798 | onThreadActivate: (threadRef: ScopedThreadRef) => void; | ||
| 799 | }) { | ||
| 800 | const terminalId = terminalIds[0]; | ||
| 801 | if (terminalId === undefined) return null; | ||
| 802 | const status = terminalStatusFromRunningIds(terminalIds); | ||
| 803 | const label = | ||
| 804 | terminalIds.length === 1 | ||
| 805 | ? "1 terminal process running. Open terminal" | ||
| 806 | : `${terminalIds.length} terminal processes running. Open terminal`; | ||
| 807 | return ( | ||
| 808 | <Tooltip> | ||
| 809 | <TooltipTrigger | ||
| 810 | render={ | ||
| 811 | <button | ||
| 812 | type="button" | ||
| 813 | aria-label={label} | ||
| 814 | data-testid={`sidebar-terminal-status-${threadRef.threadId}`} | ||
| 815 | className={cn( | ||
| 816 | "inline-flex shrink-0 cursor-pointer items-center justify-center rounded-sm outline-none hover:opacity-80 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", | ||
| 817 | status?.colorClass, | ||
| 818 | )} | ||
| 819 | onPointerDown={(event) => event.stopPropagation()} | ||
| 820 | onKeyDown={(event) => event.stopPropagation()} | ||
| 821 | onClick={(event) => { | ||
| 822 | event.preventDefault(); | ||
| 823 | event.stopPropagation(); | ||
| 824 | revealThreadTerminal(threadRef, terminalId); | ||
| 825 | onThreadActivate(threadRef); | ||
| 826 | }} | ||
| 827 | /> | ||
| 828 | } | ||
| 829 | > | ||
| 830 | <TerminalIcon | ||
| 831 | className="size-3.5 motion-safe:animate-status-pulse" | ||
| 832 | onAnimationStart={synchronizeTerminalPulse} | ||
| 833 | /> | ||
| 834 | </TooltipTrigger> | ||
| 835 | <TooltipPopup side="top">{label}</TooltipPopup> | ||
| 836 | </Tooltip> | ||
| 837 | ); | ||
| 838 | } | ||
| 839 | |||
| 789 | export function ThreadWorktreeIndicator({ | 840 | export function ThreadWorktreeIndicator({ |
| 790 | thread, | 841 | thread, |
| 791 | }: { | 842 | }: { |
apps/web/src/lib/revealThreadTerminal.test.ts created+72| ... | @@ -0,0 +1,72 @@ | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import { beforeEach, expect, it } from "vite-plus/test"; | ||
| 4 | |||
| 5 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | ||
| 6 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 7 | import { revealThreadTerminal } from "./revealThreadTerminal"; | ||
| 8 | |||
| 9 | const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("settled-thread")); | ||
| 10 | const remoteRef = scopeThreadRef(EnvironmentId.make("remote"), threadRef.threadId); | ||
| 11 | |||
| 12 | beforeEach(() => { | ||
| 13 | useTerminalUiStateStore.setState({ | ||
| 14 | terminalUiStateByThreadKey: {}, | ||
| 15 | suppressedTerminalIdsByThreadKey: {}, | ||
| 16 | }); | ||
| 17 | useRightPanelStore.setState({ byThreadKey: {} }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("reveals a server-created setup terminal without adding a default shell", () => { | ||
| 21 | revealThreadTerminal(threadRef, "setup-install"); | ||
| 22 | expect( | ||
| 23 | selectThreadTerminalUiState( | ||
| 24 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 25 | threadRef, | ||
| 26 | ), | ||
| 27 | ).toMatchObject({ | ||
| 28 | terminalOpen: true, | ||
| 29 | terminalIds: ["setup-install"], | ||
| 30 | activeTerminalId: "setup-install", | ||
| 31 | }); | ||
| 32 | }); | ||
| 33 | |||
| 34 | it("reopens the existing panel and selects a running terminal in its split", () => { | ||
| 35 | const panels = useRightPanelStore.getState(); | ||
| 36 | panels.openTerminal(threadRef, "terminal-1"); | ||
| 37 | const surfaceId = selectThreadRightPanelState( | ||
| 38 | useRightPanelStore.getState().byThreadKey, | ||
| 39 | threadRef, | ||
| 40 | ).activeSurfaceId!; | ||
| 41 | panels.splitTerminal(threadRef, surfaceId, "setup-install"); | ||
| 42 | panels.activateTerminal(threadRef, surfaceId, "terminal-1"); | ||
| 43 | panels.close(threadRef); | ||
| 44 | |||
| 45 | revealThreadTerminal(threadRef, "setup-install"); | ||
| 46 | expect( | ||
| 47 | selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef), | ||
| 48 | ).toMatchObject({ | ||
| 49 | isOpen: true, | ||
| 50 | activeSurfaceId: surfaceId, | ||
| 51 | surfaces: [ | ||
| 52 | { | ||
| 53 | terminalIds: ["terminal-1", "setup-install"], | ||
| 54 | activeTerminalId: "setup-install", | ||
| 55 | }, | ||
| 56 | ], | ||
| 57 | }); | ||
| 58 | expect( | ||
| 59 | selectThreadTerminalUiState( | ||
| 60 | useTerminalUiStateStore.getState().terminalUiStateByThreadKey, | ||
| 61 | threadRef, | ||
| 62 | ).terminalIds, | ||
| 63 | ).toEqual([]); | ||
| 64 | }); | ||
| 65 | |||
| 66 | it("reveals terminals in their own environment without changing another environment's drawer", () => { | ||
| 67 | revealThreadTerminal(threadRef, "default"); | ||
| 68 | revealThreadTerminal(remoteRef, "setup-install"); | ||
| 69 | const state = useTerminalUiStateStore.getState().terminalUiStateByThreadKey; | ||
| 70 | expect(selectThreadTerminalUiState(state, threadRef).terminalIds).toEqual(["default"]); | ||
| 71 | expect(selectThreadTerminalUiState(state, remoteRef).terminalIds).toEqual(["setup-install"]); | ||
| 72 | }); | ||
apps/web/src/lib/revealThreadTerminal.ts created+21| ... | @@ -0,0 +1,21 @@ | ||
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore"; | ||
| 4 | import { useTerminalUiStateStore } from "../terminalUiStateStore"; | ||
| 5 | |||
| 6 | /** Reveal an existing terminal in the panel that owns it, without opening another PTY. */ | ||
| 7 | export function revealThreadTerminal(threadRef: ScopedThreadRef, terminalId: string) { | ||
| 8 | const panels = useRightPanelStore.getState(); | ||
| 9 | const surface = selectThreadRightPanelState(panels.byThreadKey, threadRef).surfaces.find( | ||
| 10 | (surface) => surface.kind === "terminal" && surface.terminalIds.includes(terminalId), | ||
| 11 | ); | ||
| 12 | if (surface) { | ||
| 13 | panels.activateTerminal(threadRef, surface.id, terminalId); | ||
| 14 | panels.activateSurface(threadRef, surface.id); | ||
| 15 | return; | ||
| 16 | } | ||
| 17 | useTerminalUiStateStore.getState().ensureTerminal(threadRef, terminalId, { | ||
| 18 | open: true, | ||
| 19 | active: true, | ||
| 20 | }); | ||
| 21 | } | ||
scripts/setup-worktree.ts+4-2| ... | @@ -19,10 +19,12 @@ if (!projectRoot) { | ... | @@ -19,10 +19,12 @@ if (!projectRoot) { |
| 19 | const worktree = NodePath.dirname(import.meta.dirname); | 19 | const worktree = NodePath.dirname(import.meta.dirname); |
| 20 | 20 | ||
| 21 | // `shell` resolves `vp` through PATH, including Windows command shims. | 21 | // `shell` resolves `vp` through PATH, including Windows command shims. |
| 22 | const install = NodeChildProcess.spawnSync("vp i", { | 22 | // Setup has no one answering install prompts. Pass pnpm's confirmation flag |
| 23 | // through vp and leave stdin closed so other prompts fail instead of hanging. | ||
| 24 | const install = NodeChildProcess.spawnSync("vp i -- --yes", { | ||
| 23 | cwd: worktree, | 25 | cwd: worktree, |
| 24 | shell: true, | 26 | shell: true, |
| 25 | stdio: "inherit", | 27 | stdio: ["ignore", "inherit", "inherit"], |
| 26 | }); | 28 | }); |
| 27 | if (install.status !== 0) process.exit(install.status ?? 1); | 29 | if (install.status !== 0) process.exit(install.status ?? 1); |
| 28 | 30 |