Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4803,6 +4803,15 @@ export default function ChatView(props: ChatViewProps) {
)
return;
if (nextOpen && hasTerminalWriteAccess() && terminalUiState.terminalIds.length === 0) {
// Server-created setup terminals exist before this client's drawer mounts.
// Reuse them before allocating a shell from the still-empty UI state.
const existingTerminalId =
runningTerminalIds.find((id) => !panelTerminalIds.has(id)) ??
activeServerOrderedTerminalIds.find((id) => !panelTerminalIds.has(id));
if (existingTerminalId !== undefined) {
storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true });
return;
}
if (!activeThreadId || !activeProject) {
return;
}
Expand All @@ -4829,6 +4838,7 @@ export default function ChatView(props: ChatViewProps) {
}
setTerminalOpen(nextOpen);
}, [
activeServerOrderedTerminalIds,
activeProject,
activeThreadId,
activeThreadRef,
Expand All @@ -4837,6 +4847,8 @@ export default function ChatView(props: ChatViewProps) {
environmentId,
gitCwd,
openTerminal,
panelTerminalIds,
runningTerminalIds,
hasTerminalWriteAccess,
setTerminalOpen,
storeEnsureTerminal,
Expand Down
29 changes: 6 additions & 23 deletions apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ import {
Globe2Icon,
SearchIcon,
SquarePenIcon,
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
import {
ChangeRequestStatusIcon,
prStatusIndicator,
PrStatusTooltipContent,
terminalStatusFromRunningIds,
synchronizeTerminalPulse,
ThreadTerminalStatusButton,
ThreadStatusLabel,
ThreadWorktreeIndicator,
useLinkedThreadPullRequest,
Expand Down Expand Up @@ -506,7 +504,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
? resolveThreadCurrentPullRequestLink(thread.pullRequests)
: null;
const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
const isConfirmingArchive =
canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning;
const threadMetaClassName = isConfirmingArchive
Expand Down Expand Up @@ -849,25 +846,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
</Tooltip>
)}
<ThreadWorktreeIndicator thread={thread} />
{terminalStatus && (
<Tooltip>
<TooltipTrigger
render={
<span
role="img"
aria-label={terminalStatus.label}
className={`inline-flex items-center justify-center ${terminalStatus.colorClass}`}
/>
}
>
<TerminalIcon
className={`size-3 ${terminalStatus.pulse ? "motion-safe:animate-status-pulse" : ""}`}
onAnimationStart={synchronizeTerminalPulse}
/>
</TooltipTrigger>
<TooltipPopup side="top">{terminalStatus.label}</TooltipPopup>
</Tooltip>
)}
<ThreadTerminalStatusButton
threadRef={threadRef}
terminalIds={runningTerminalIds}
onThreadActivate={navigateToThread}
/>
<div
className={`flex min-w-12 justify-end ${
isRemoteThread ? "max-sm:min-w-24" : "max-sm:min-w-20"
Expand Down
26 changes: 11 additions & 15 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { type EnvironmentId, type ScopedProjectRef } from "@t3tools/contracts";
import { ThreadHoverCard, ThreadHoverCardPopup } from "./ThreadHoverCard";
import { CollapsibleSectionHeader } from "./ui/collapsible-section-header";
import { setThreadChangeRequestSnapshot } from "./ThreadStatusIndicators";
import {
setThreadChangeRequestSnapshot,
ThreadTerminalStatusButton,
} from "./ThreadStatusIndicators";
import { ThreadContextDragGhost } from "./chat/ThreadContextDragGhost";
import {
dropThreadContext,
Expand Down Expand Up @@ -248,7 +251,6 @@ import {
terminalStatusFromRunningIds,
threadChangeRequestSnapshotsAtom,
type ThreadChangeRequestSnapshot,
synchronizeTerminalPulse,
type TerminalStatusIndicator,
useLinkedThreadPullRequest,
ThreadSearchPullRequestNumber,
Expand Down Expand Up @@ -1725,19 +1727,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onOpenPullRequest={handlePrClick}
/>
) : null;
const terminalStatusIcon = terminalStatus ? (
<span
role="img"
aria-label={terminalProcessLabel(terminalProcessCount)}
data-testid={`sidebar-terminal-status-${thread.id}`}
className={cn("inline-flex shrink-0 items-center justify-center", terminalStatus.colorClass)}
>
<TerminalIcon
className={cn("size-3.5", terminalStatus.pulse && "motion-safe:animate-status-pulse")}
onAnimationStart={synchronizeTerminalPulse}
/>
</span>
) : null;
const terminalStatusIcon = (
<ThreadTerminalStatusButton
threadRef={threadRef}
terminalIds={runningTerminalIds}
onThreadActivate={onThreadActivate}
/>
);
// Same pen the new-thread draft rows lead with, so both kinds of unsent
// work read the same way in the list.
const draftIndicator = hasUnsentDraft ? (
Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull
import {
resolveEnvironmentMachineKind,
type EnvironmentId,
type ScopedThreadRef,
type ThreadLinkedPullRequest,
type ThreadPullRequestLink,
type VcsStatusResult,
Expand All @@ -35,6 +36,7 @@ import {
type ComponentProps,
} from "react";
import { cn } from "../lib/utils";
import { revealThreadTerminal } from "../lib/revealThreadTerminal";

import { parseChangeRequestUrl } from "../lib/openPullRequestLink";
import { useEnvironmentQuery } from "../state/query";
Expand Down Expand Up @@ -786,6 +788,55 @@ export function synchronizeTerminalPulse(event: AnimationEvent<SVGSVGElement>) {
}
}

export function ThreadTerminalStatusButton({
threadRef,
terminalIds,
onThreadActivate,
}: {
threadRef: ScopedThreadRef;
terminalIds: readonly string[];
onThreadActivate: (threadRef: ScopedThreadRef) => void;
}) {
const terminalId = terminalIds[0];
if (terminalId === undefined) return null;
const status = terminalStatusFromRunningIds(terminalIds);
const label =
terminalIds.length === 1
? "1 terminal process running. Open terminal"
: `${terminalIds.length} terminal processes running. Open terminal`;
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={label}
data-testid={`sidebar-terminal-status-${threadRef.threadId}`}
className={cn(
"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",
status?.colorClass,
)}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
revealThreadTerminal(threadRef, terminalId);
onThreadActivate(threadRef);
}}
/>
}
>
<TerminalIcon
className="size-3.5 motion-safe:animate-status-pulse"
onAnimationStart={synchronizeTerminalPulse}
/>
</TooltipTrigger>
<TooltipPopup side="top">{label}</TooltipPopup>
</Tooltip>
);
}

export function ThreadWorktreeIndicator({
thread,
}: {
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/lib/revealThreadTerminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { beforeEach, expect, it } from "vite-plus/test";

import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
import { revealThreadTerminal } from "./revealThreadTerminal";

const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("settled-thread"));
const remoteRef = scopeThreadRef(EnvironmentId.make("remote"), threadRef.threadId);

beforeEach(() => {
useTerminalUiStateStore.setState({
terminalUiStateByThreadKey: {},
suppressedTerminalIdsByThreadKey: {},
});
useRightPanelStore.setState({ byThreadKey: {} });
});

it("reveals a server-created setup terminal without adding a default shell", () => {
revealThreadTerminal(threadRef, "setup-install");
expect(
selectThreadTerminalUiState(
useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
threadRef,
),
).toMatchObject({
terminalOpen: true,
terminalIds: ["setup-install"],
activeTerminalId: "setup-install",
});
});

it("reopens the existing panel and selects a running terminal in its split", () => {
const panels = useRightPanelStore.getState();
panels.openTerminal(threadRef, "terminal-1");
const surfaceId = selectThreadRightPanelState(
useRightPanelStore.getState().byThreadKey,
threadRef,
).activeSurfaceId!;
panels.splitTerminal(threadRef, surfaceId, "setup-install");
panels.activateTerminal(threadRef, surfaceId, "terminal-1");
panels.close(threadRef);

revealThreadTerminal(threadRef, "setup-install");
expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef),
).toMatchObject({
isOpen: true,
activeSurfaceId: surfaceId,
surfaces: [
{
terminalIds: ["terminal-1", "setup-install"],
activeTerminalId: "setup-install",
},
],
});
expect(
selectThreadTerminalUiState(
useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
threadRef,
).terminalIds,
).toEqual([]);
});

it("reveals terminals in their own environment without changing another environment's drawer", () => {
revealThreadTerminal(threadRef, "default");
revealThreadTerminal(remoteRef, "setup-install");
const state = useTerminalUiStateStore.getState().terminalUiStateByThreadKey;
expect(selectThreadTerminalUiState(state, threadRef).terminalIds).toEqual(["default"]);
expect(selectThreadTerminalUiState(state, remoteRef).terminalIds).toEqual(["setup-install"]);
});
21 changes: 21 additions & 0 deletions apps/web/src/lib/revealThreadTerminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ScopedThreadRef } from "@t3tools/contracts";

import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
import { useTerminalUiStateStore } from "../terminalUiStateStore";

/** Reveal an existing terminal in the panel that owns it, without opening another PTY. */
export function revealThreadTerminal(threadRef: ScopedThreadRef, terminalId: string) {
const panels = useRightPanelStore.getState();
const surface = selectThreadRightPanelState(panels.byThreadKey, threadRef).surfaces.find(
(surface) => surface.kind === "terminal" && surface.terminalIds.includes(terminalId),
);
if (surface) {
panels.activateTerminal(threadRef, surface.id, terminalId);
panels.activateSurface(threadRef, surface.id);
return;
}
useTerminalUiStateStore.getState().ensureTerminal(threadRef, terminalId, {
open: true,
active: true,
});
}
6 changes: 4 additions & 2 deletions scripts/setup-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ if (!projectRoot) {
const worktree = NodePath.dirname(import.meta.dirname);

// `shell` resolves `vp` through PATH, including Windows command shims.
const install = NodeChildProcess.spawnSync("vp i", {
// Setup has no one answering install prompts. Pass pnpm's confirmation flag
// through vp and leave stdin closed so other prompts fail instead of hanging.
const install = NodeChildProcess.spawnSync("vp i -- --yes", {
cwd: worktree,
shell: true,
stdio: "inherit",
stdio: ["ignore", "inherit", "inherit"],
});
if (install.status !== 0) process.exit(install.status ?? 1);

Expand Down
Loading