Skip to content

Commit df49701

Browse files
refactor(web): open and close thread terminals through one hook
The drawer, the right panel terminal tabs, the drawer toggle and script runs each built the terminal open input, allocated an id and handled the close fallback on their own. useThreadTerminalActions now owns id allocation, the default cwd and runtime env, and close-with-exit-fallback. Panel terminal close now also falls back to typing `exit` when the close command fails, like the drawer already did. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent e13566c commit df49701

3 files changed

Lines changed: 250 additions & 371 deletions

File tree

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 59 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { useLoadBalancedEnvironment } from "../hooks/useLoadBalancedEnvironment";
2626
import { useScratchProject } from "../hooks/useScratchProject";
2727
import { useStableCallback } from "../hooks/useStableCallback";
28+
import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions";
2829
import { usePendingThreadRequests } from "./chat/usePendingThreadRequests";
2930
import { isScratchProject } from "@t3tools/client-runtime/state/projects";
3031
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
@@ -76,7 +77,6 @@ import {
7677
resolveEnvironmentMachineKind,
7778
resolveProjectProviderInstanceEnabled,
7879
RuntimeMode,
79-
TerminalOpenInput,
8080
type WorktreeSetupSnapshot,
8181
} from "@t3tools/contracts";
8282
import { type EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection";
@@ -125,11 +125,7 @@ import {
125125
resolvePromptInjectedEffort,
126126
resolveSelectableModel,
127127
} from "@t3tools/shared/model";
128-
import {
129-
projectScriptCwd,
130-
projectScriptRuntimeEnv,
131-
resolveProjectScripts,
132-
} from "@t3tools/shared/projectScripts";
128+
import { projectScriptCwd, resolveProjectScripts } from "@t3tools/shared/projectScripts";
133129
import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
134130
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
135131
import {
@@ -140,7 +136,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
140136
import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl";
141137
import { truncate } from "@t3tools/shared/String";
142138
import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
143-
import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
139+
import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
144140
import { Debouncer } from "@tanstack/react-pacer";
145141
import { useAtomValue } from "@effect/atom-react";
146142
import { Atom } from "effect/reactivity";
@@ -400,7 +396,6 @@ import {
400396
primaryServerKeybindingsAtom,
401397
serverEnvironment,
402398
} from "../state/server";
403-
import { terminalEnvironment } from "../state/terminal";
404399
import { threadEnvironment } from "../state/threads";
405400
import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation";
406401
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
@@ -1051,9 +1046,6 @@ export default function ChatView(props: ChatViewProps) {
10511046
const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, {
10521047
reportFailure: false,
10531048
});
1054-
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
1055-
const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write");
1056-
const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close");
10571049
const createThread = useOrchestrationCommand(threadEnvironment.create, { reportFailure: false });
10581050
const deleteThread = useOrchestrationCommand(threadEnvironment.delete, { reportFailure: false });
10591051
const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
@@ -1668,6 +1660,7 @@ export default function ChatView(props: ChatViewProps) {
16681660
return labels;
16691661
}, [activeThreadKnownSessions]);
16701662
const activeThreadRef = useActiveThreadRef(activeThread);
1663+
const terminalActions = useThreadTerminalActions(activeThreadRef);
16711664
const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
16721665
const activeEnvironmentServerBrowser = useEnvironmentSupportsServerBrowser(
16731666
activeThreadRef?.environmentId ?? null,
@@ -1759,18 +1752,7 @@ export default function ChatView(props: ChatViewProps) {
17591752
),
17601753
[rightPanelState.surfaces],
17611754
);
1762-
const allocatableActiveTerminalIds = useMemo(
1763-
() => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
1764-
[activeKnownTerminalIds, panelTerminalIds],
1765-
);
17661755
const canReuseTerminal = activeThreadKnownSessionsRaw !== null;
1767-
const allocateTerminalId = () =>
1768-
nextTerminalId(
1769-
allocatableActiveTerminalIds,
1770-
canReuseTerminal && readEnvironmentScope(environmentId, AuthTerminalReadScope)
1771-
? undefined
1772-
: randomUUID(),
1773-
);
17741756
const previewPanelOpen = activeRightPanelKind === "preview" && browserAvailable;
17751757
const rightPanelOpen = rightPanelState.isOpen;
17761758
const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } =
@@ -4011,119 +3993,40 @@ export default function ChatView(props: ChatViewProps) {
40113993
storeEnsureTerminal(activeThreadRef, existingTerminalId, { open: true, active: true });
40123994
return;
40133995
}
4014-
if (!activeThreadId || !activeProject) {
4015-
return;
4016-
}
4017-
const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4018-
if (!cwdForOpen) {
4019-
return;
4020-
}
4021-
const terminalId = allocateTerminalId();
4022-
storeEnsureTerminal(activeThreadRef, terminalId, { open: true });
4023-
void openTerminal({
4024-
environmentId,
4025-
input: {
4026-
threadId: activeThreadId,
4027-
terminalId,
4028-
cwd: cwdForOpen,
4029-
...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4030-
env: projectScriptRuntimeEnv({
4031-
project: { cwd: activeProject.workspaceRoot },
4032-
worktreePath: activeThreadWorktreePath,
4033-
}),
4034-
},
4035-
});
3996+
void terminalActions.launchTerminal({
3997+
place: (terminalId) => storeEnsureTerminal(activeThreadRef, terminalId, { open: true }),
3998+
})?.result;
40363999
return;
40374000
}
40384001
setTerminalOpen(nextOpen);
40394002
};
40404003
const toggleTerminalVisibilityStable = useStableCallback(toggleTerminalVisibility);
40414004
const splitTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
4042-
if (
4043-
!hasTerminalWriteAccess() ||
4044-
!activeThreadRef ||
4045-
hasReachedSplitLimit ||
4046-
!activeThreadId ||
4047-
!activeProject
4048-
) {
4049-
return;
4050-
}
4051-
const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4052-
if (!cwdForOpen) {
4053-
return;
4054-
}
4055-
const terminalId = allocateTerminalId();
4056-
if (direction === "vertical") {
4057-
storeSplitTerminalVertical(activeThreadRef, terminalId);
4058-
} else {
4059-
storeSplitTerminal(activeThreadRef, terminalId);
4060-
}
4061-
setTerminalFocusRequestId((value) => value + 1);
4062-
void openTerminal({
4063-
environmentId,
4064-
input: {
4065-
threadId: activeThreadId,
4066-
terminalId,
4067-
cwd: cwdForOpen,
4068-
...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4069-
env: projectScriptRuntimeEnv({
4070-
project: { cwd: activeProject.workspaceRoot },
4071-
worktreePath: activeThreadWorktreePath,
4072-
}),
4073-
},
4005+
if (!activeThreadRef || hasReachedSplitLimit) return;
4006+
const launched = terminalActions.launchTerminal({
4007+
place: (terminalId) =>
4008+
direction === "vertical"
4009+
? storeSplitTerminalVertical(activeThreadRef, terminalId)
4010+
: storeSplitTerminal(activeThreadRef, terminalId),
40744011
});
4012+
if (!launched) return;
4013+
setTerminalFocusRequestId((value) => value + 1);
4014+
void launched.result;
40754015
};
40764016
const splitTerminalStable = useStableCallback(splitTerminal);
40774017
const createNewTerminal = () => {
4078-
if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) {
4079-
return;
4080-
}
4081-
const cwdForOpen = gitCwd ?? activeProject.workspaceRoot;
4082-
if (!cwdForOpen) {
4083-
return;
4084-
}
4085-
const terminalId = allocateTerminalId();
4086-
storeNewTerminal(activeThreadRef, terminalId);
4087-
setTerminalFocusRequestId((value) => value + 1);
4088-
void openTerminal({
4089-
environmentId,
4090-
input: {
4091-
threadId: activeThreadId,
4092-
terminalId,
4093-
cwd: cwdForOpen,
4094-
...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
4095-
env: projectScriptRuntimeEnv({
4096-
project: { cwd: activeProject.workspaceRoot },
4097-
worktreePath: activeThreadWorktreePath,
4098-
}),
4099-
},
4018+
if (!activeThreadRef) return;
4019+
const launched = terminalActions.launchTerminal({
4020+
place: (terminalId) => storeNewTerminal(activeThreadRef, terminalId),
41004021
});
4022+
if (!launched) return;
4023+
setTerminalFocusRequestId((value) => value + 1);
4024+
void launched.result;
41014025
};
41024026
const createNewTerminalStable = useStableCallback(createNewTerminal);
41034027
const closeTerminal = (terminalId: string) => {
4104-
if (!hasTerminalWriteAccess() || !activeThreadId || !activeThreadRef) return;
4105-
const fallbackExitWrite = () =>
4106-
writeTerminal({
4107-
environmentId,
4108-
input: { threadId: activeThreadId, terminalId, data: "exit\n" },
4109-
});
4110-
void (async () => {
4111-
const closeResult = await closeTerminalMutation({
4112-
environmentId,
4113-
input: {
4114-
threadId: activeThreadId,
4115-
terminalId,
4116-
deleteHistory: true,
4117-
},
4118-
});
4119-
if (
4120-
closeResult._tag === "Failure" &&
4121-
!isAtomCommandInterrupted(closeResult) &&
4122-
hasTerminalWriteAccess()
4123-
) {
4124-
await fallbackExitWrite();
4125-
}
4126-
})();
4028+
if (!hasTerminalWriteAccess() || !activeThreadRef) return;
4029+
terminalActions.closeTerminalSession(terminalId);
41274030
storeCloseTerminal(activeThreadRef, terminalId);
41284031
setTerminalFocusRequestId((value) => value + 1);
41294032
};
@@ -4166,39 +4069,20 @@ export default function ChatView(props: ChatViewProps) {
41664069
}
41674070
setTerminalFocusRequestId((value) => value + 1);
41684071

4169-
const runtimeEnv = projectScriptRuntimeEnv({
4170-
project: {
4171-
cwd: activeProject.workspaceRoot,
4172-
},
4173-
worktreePath: targetWorktreePath,
4072+
const launched = terminalActions.launchTerminal({
4073+
place: (terminalId) =>
4074+
shouldCreateNewTerminal
4075+
? storeNewTerminal(activeThreadRef, terminalId)
4076+
: storeSetActiveTerminal(activeThreadRef, terminalId),
4077+
...(shouldCreateNewTerminal
4078+
? { size: { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } }
4079+
: { terminalId: baseTerminalId }),
4080+
location: { cwd: targetCwd, worktreePath: targetWorktreePath },
41744081
...(options?.env ? { extraEnv: options.env } : {}),
41754082
});
4176-
const targetTerminalId = shouldCreateNewTerminal ? allocateTerminalId() : baseTerminalId;
4177-
const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
4178-
? {
4179-
threadId: activeThreadId,
4180-
terminalId: targetTerminalId,
4181-
cwd: targetCwd,
4182-
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
4183-
env: runtimeEnv,
4184-
cols: SCRIPT_TERMINAL_COLS,
4185-
rows: SCRIPT_TERMINAL_ROWS,
4186-
}
4187-
: {
4188-
threadId: activeThreadId,
4189-
terminalId: targetTerminalId,
4190-
cwd: targetCwd,
4191-
...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}),
4192-
env: runtimeEnv,
4193-
};
4194-
4195-
if (shouldCreateNewTerminal) {
4196-
storeNewTerminal(activeThreadRef, targetTerminalId);
4197-
} else {
4198-
storeSetActiveTerminal(activeThreadRef, targetTerminalId);
4199-
}
4200-
4201-
const openResult = await openTerminal({ environmentId, input: openTerminalInput });
4083+
if (!launched) return;
4084+
const targetTerminalId = launched.terminalId;
4085+
const openResult = await launched.result;
42024086
if (openResult._tag === "Failure") {
42034087
if (!isAtomCommandInterrupted(openResult)) {
42044088
const error = squashAtomCommandFailure(openResult);
@@ -4210,15 +4094,11 @@ export default function ChatView(props: ChatViewProps) {
42104094
return;
42114095
}
42124096

4213-
if (!hasTerminalWriteAccess()) return;
4214-
const writeResult = await writeTerminal({
4215-
environmentId,
4216-
input: {
4217-
threadId: activeThreadId,
4218-
terminalId: targetTerminalId,
4219-
data: `${script.command}\r`,
4220-
},
4221-
});
4097+
const writeResult = await terminalActions.writeToTerminal(
4098+
targetTerminalId,
4099+
`${script.command}\r`,
4100+
);
4101+
if (!writeResult) return;
42224102
if (writeResult._tag === "Failure") {
42234103
if (!isAtomCommandInterrupted(writeResult)) {
42244104
const error = squashAtomCommandFailure(writeResult);
@@ -5003,56 +4883,34 @@ export default function ChatView(props: ChatViewProps) {
50034883
};
50044884
const togglePreviewPanelStable = useStableCallback(togglePreviewPanel);
50054885
const addTerminalSurface = () => {
5006-
if (!hasTerminalWriteAccess() || !activeThreadRef || !activeThreadId || !activeProject) return;
5007-
const cwd = gitCwd ?? activeProject.workspaceRoot;
5008-
const terminalId = allocateTerminalId();
5009-
useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
5010-
setTerminalFocusRequestId((value) => value + 1);
5011-
void openTerminal({
5012-
environmentId: activeThreadRef.environmentId,
5013-
input: {
5014-
threadId: activeThreadId,
5015-
terminalId,
5016-
cwd,
5017-
...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
5018-
env: projectScriptRuntimeEnv({
5019-
project: { cwd: activeProject.workspaceRoot },
5020-
worktreePath: activeThreadWorktreePath,
5021-
}),
5022-
},
4886+
if (!activeThreadRef) return;
4887+
const launched = terminalActions.launchTerminal({
4888+
place: (terminalId) =>
4889+
useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId),
50234890
});
4891+
if (!launched) return;
4892+
setTerminalFocusRequestId((value) => value + 1);
4893+
void launched.result;
50244894
};
50254895
const addTerminalSurfaceStable = useStableCallback(addTerminalSurface);
50264896
const splitPanelTerminal = (direction: "horizontal" | "vertical" = "horizontal") => {
50274897
if (
5028-
!hasTerminalWriteAccess() ||
50294898
!activeThreadRef ||
5030-
!activeThreadId ||
5031-
!activeProject ||
50324899
activeRightPanelSurface?.kind !== "terminal" ||
50334900
activeRightPanelSurface.terminalIds.length >= MAX_TERMINALS_PER_GROUP
50344901
) {
50354902
return;
50364903
}
5037-
const terminalId = allocateTerminalId();
5038-
const cwd = gitCwd ?? activeProject.workspaceRoot;
5039-
useRightPanelStore
5040-
.getState()
5041-
.splitTerminal(activeThreadRef, activeRightPanelSurface.id, terminalId, direction);
5042-
setTerminalFocusRequestId((value) => value + 1);
5043-
void openTerminal({
5044-
environmentId: activeThreadRef.environmentId,
5045-
input: {
5046-
threadId: activeThreadId,
5047-
terminalId,
5048-
cwd,
5049-
...(activeThreadWorktreePath != null ? { worktreePath: activeThreadWorktreePath } : {}),
5050-
env: projectScriptRuntimeEnv({
5051-
project: { cwd: activeProject.workspaceRoot },
5052-
worktreePath: activeThreadWorktreePath,
5053-
}),
5054-
},
4904+
const surfaceId = activeRightPanelSurface.id;
4905+
const launched = terminalActions.launchTerminal({
4906+
place: (terminalId) =>
4907+
useRightPanelStore
4908+
.getState()
4909+
.splitTerminal(activeThreadRef, surfaceId, terminalId, direction),
50554910
});
4911+
if (!launched) return;
4912+
setTerminalFocusRequestId((value) => value + 1);
4913+
void launched.result;
50564914
};
50574915
const splitPanelTerminalStable = useStableCallback(splitPanelTerminal);
50584916
const splitPanelTerminalVertical = () => {
@@ -5074,10 +4932,7 @@ export default function ChatView(props: ChatViewProps) {
50744932
activeRightPanelSurface?.kind !== "terminal"
50754933
)
50764934
return;
5077-
void closeTerminalMutation({
5078-
environmentId: activeThreadRef.environmentId,
5079-
input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
5080-
});
4935+
terminalActions.closeTerminalSession(terminalId);
50814936
storeCloseTerminal(activeThreadRef, terminalId);
50824937
useRightPanelStore
50834938
.getState()
@@ -5165,10 +5020,7 @@ export default function ChatView(props: ChatViewProps) {
51655020
) {
51665021
for (const terminalId of surface.terminalIds) {
51675022
storeCloseTerminal(activeThreadRef, terminalId);
5168-
void closeTerminalMutation({
5169-
environmentId: activeThreadRef.environmentId,
5170-
input: { threadId: activeThreadRef.threadId, terminalId, deleteHistory: true },
5171-
});
5023+
terminalActions.closeTerminalSession(terminalId);
51725024
}
51735025
}
51745026
}

0 commit comments

Comments
 (0)