Skip to content
Closed
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
101 changes: 80 additions & 21 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DEFAULT_TERMINAL_ID,
EnvironmentId,
ThreadId,
type TerminalShellProfile,
sessionGrantsScope,
} from "@t3tools/contracts";
import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal";
Expand Down Expand Up @@ -71,6 +72,7 @@ import {
} from "./terminalBufferReplay";
import {
useInitialTerminalLaunchLocation,
stagePendingTerminalLaunch,
takePendingTerminalLaunch,
type PendingTerminalLaunch,
} from "./terminalLaunchContext";
Expand Down Expand Up @@ -98,6 +100,7 @@ function TerminalHeader(props: {
readonly subtitle: string;
readonly isEnvironmentReady: boolean;
readonly canOperateTerminal: boolean;
readonly canSelectShell: boolean;
readonly fontSize: number;
readonly terminalId: string;
readonly sessions: ReadonlyArray<TerminalMenuSession>;
Expand All @@ -106,7 +109,8 @@ function TerminalHeader(props: {
readonly onCloseTerminal: () => void;
readonly onDecreaseFontSize: () => void;
readonly onIncreaseFontSize: () => void;
readonly onOpenNewTerminal: () => void;
readonly shells: ReadonlyArray<TerminalShellProfile>;
readonly onOpenNewTerminal: (shell?: string) => void;
readonly onSelectTerminal: (terminalId: string) => void;
}) {
return (
Expand Down Expand Up @@ -168,8 +172,24 @@ function TerminalHeader(props: {
title: "Open new terminal",
icon: "plus",
subtitle: `Start another shell in ${basename(props.workspaceRoot) ?? "this workspace"}`,
onPress: props.onOpenNewTerminal,
onPress: () => props.onOpenNewTerminal(),
},
...(props.shells.length > 0
? [
{
id: "terminal-new-with-shell",
title: "New terminal with…",
icon: "plus",
disabled: !props.canSelectShell,
items: props.shells.map((shell) => ({
id: `terminal-new-shell:${shell.id}`,
title: shell.label,
disabled: !props.canSelectShell,
onPress: () => props.onOpenNewTerminal(shell.id),
})),
},
]
: []),
],
},
]
Expand Down Expand Up @@ -261,6 +281,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
const clearTerminal = useAtomCommand(terminalEnvironment.clear, "terminal clear");
const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const listShells = useAtomCommand(terminalEnvironment.listShells, "terminal list shells");
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
const connectionsReady = useConnectionsReady();
const params = props.route.params;
Expand Down Expand Up @@ -433,6 +454,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
cols: initialAttachGridSize.cols,
rows: initialAttachGridSize.rows,
...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}),
...(pendingLaunch?.shell ? { shell: pendingLaunch.shell } : {}),
...(pendingLaunch ? { restartIfNotRunning: true } : {}),
}
: null,
Expand Down Expand Up @@ -964,6 +986,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
cols: terminalAttachInput.cols,
rows: terminalAttachInput.rows,
...(terminalAttachInput.env ? { env: terminalAttachInput.env } : {}),
...(terminalAttachInput.shell ? { shell: terminalAttachInput.shell } : {}),
},
});
return result._tag === "Success";
Expand Down Expand Up @@ -1001,29 +1024,63 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
[navigateAwayAfterExit, navigation, terminalKey],
);

const handleOpenNewTerminal = useCallback(() => {
if (
!selectedThread ||
!readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope)
) {
const [shells, setShells] = useState<ReadonlyArray<TerminalShellProfile>>([]);
const shellsEnvironmentId = selectedThread?.environmentId ?? null;
useEffect(() => {
setShells([]);
if (shellsEnvironmentId === null || !canOperateTerminal || !isEnvironmentReady) {
return;
}
let cancelled = false;
void listShells({ environmentId: shellsEnvironmentId, input: {} }).then((result) => {
if (!cancelled && result._tag === "Success") {
setShells(result.value.shells);
}
});
return () => {
cancelled = true;
};
}, [canOperateTerminal, isEnvironmentReady, listShells, shellsEnvironmentId]);

navigation.dispatch(
StackActions.replace("ThreadTerminal", {
environmentId: String(selectedThread.environmentId),
threadId: String(selectedThread.id),
terminalId: nextOpenTerminalId({
listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
activeRouteTerminalId: terminalId,
...(knownSessions === null ||
!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
? { uniqueSuffix: uuidv4() }
: {}),
const handleOpenNewTerminal = useCallback(
(shell?: string) => {
if (
!selectedThread ||
!readEnvironmentScope(selectedThread.environmentId, AuthTerminalOperateScope) ||
(shell !== undefined && launchLocation === null)
) {
return;
}

const nextTerminalId = nextOpenTerminalId({
listedTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
activeRouteTerminalId: terminalId,
...(knownSessions === null ||
!readEnvironmentScope(selectedThread.environmentId, AuthTerminalReadScope)
? { uniqueSuffix: uuidv4() }
: {}),
});
if (shell !== undefined && launchLocation !== null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High terminal/ThreadTerminalRouteScreen.tsx:1060

Selecting a shell while launchLocation is still null opens the new terminal with the default shell, silently discarding the user's choice. The handler stages the selected shell only when launchLocation is non-null, but still navigates; disable shell selection until the location is ready or preserve the shell choice until it can be staged.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx around line 1060:

Selecting a shell while `launchLocation` is still `null` opens the new terminal with the default shell, silently discarding the user's choice. The handler stages the selected shell only when `launchLocation` is non-null, but still navigates; disable shell selection until the location is ready or preserve the shell choice until it can be staged.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a33176a. Mobile shell selection is disabled until launchLocation is available, and the handler also rejects an explicit shell choice while the location is unresolved. This prevents navigation from discarding the selected shell.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

stagePendingTerminalLaunch({
target: {
environmentId: selectedThread.environmentId,
threadId: selectedThread.id,
terminalId: nextTerminalId,
},
launch: { ...launchLocation, shell },
});
}

navigation.dispatch(
StackActions.replace("ThreadTerminal", {
environmentId: String(selectedThread.environmentId),
threadId: String(selectedThread.id),
terminalId: nextTerminalId,
}),
}),
);
}, [knownSessions, navigation, selectedThread, terminalId, terminalMenuSessions]);
);
},
[knownSessions, launchLocation, navigation, selectedThread, terminalId, terminalMenuSessions],
);

const handleDecreaseFontSize = useCallback(() => {
setTerminalFontSize(stepTerminalFontSize(fontSize, -1));
Expand Down Expand Up @@ -1144,6 +1201,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
) : null}
<TerminalHeader
canOperateTerminal={canOperateTerminal}
canSelectShell={canOperateTerminal && launchLocation !== null}
subtitle={headerSubtitle}
isEnvironmentReady={isEnvironmentReady}
fontSize={fontSize}
Expand All @@ -1157,6 +1215,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps)
onCloseTerminal={handleCloseTerminal}
onDecreaseFontSize={handleDecreaseFontSize}
onIncreaseFontSize={handleIncreaseFontSize}
shells={shells}
onOpenNewTerminal={handleOpenNewTerminal}
onSelectTerminal={handleSelectTerminal}
/>
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/terminal/terminalLaunchContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,19 @@ describe("pending terminal launches", () => {
});
expect(takePendingTerminalLaunch(target)).toBeNull();
});

it("keeps the shell chosen for a new terminal", () => {
const target = {
environmentId: EnvironmentId.make("env-1"),
threadId: ThreadId.make("thread-1"),
terminalId: "term-3",
};

stagePendingTerminalLaunch({
target,
launch: { cwd: "/repo", worktreePath: null, shell: "bash" },
});

expect(takePendingTerminalLaunch(target)?.shell).toBe("bash");
});
});
4 changes: 3 additions & 1 deletion apps/mobile/src/features/terminal/terminalLaunchContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import type { EnvironmentId, TerminalShellSetting, ThreadId } from "@t3tools/contracts";
import { useEffect, useState } from "react";

interface TerminalLocationLike {
Expand All @@ -17,6 +17,7 @@ export interface PendingTerminalLaunch {
readonly worktreePath: string | null;
readonly env?: Record<string, string>;
readonly initialInput?: string;
readonly shell?: TerminalShellSetting;
}

const pendingTerminalLaunches = new Map<string, PendingTerminalLaunch>();
Expand All @@ -34,6 +35,7 @@ export function stagePendingTerminalLaunch(input: {
worktreePath: input.launch.worktreePath,
env: input.launch.env ? { ...input.launch.env } : undefined,
initialInput: input.launch.initialInput,
shell: input.launch.shell,
});
}

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.terminalClear]: AuthTerminalOperateScope,
[WS_METHODS.terminalRestart]: AuthTerminalOperateScope,
[WS_METHODS.terminalClose]: AuthTerminalOperateScope,
[WS_METHODS.terminalListShells]: AuthTerminalOperateScope,
[WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope,
[WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,
[WS_METHODS.previewOpen]: AuthPreviewOperateScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const RPC_AGGREGATES = {
[WS_METHODS.terminalClear]: "terminal",
[WS_METHODS.terminalRestart]: "terminal",
[WS_METHODS.terminalClose]: "terminal",
[WS_METHODS.terminalListShells]: "terminal",
[WS_METHODS.subscribeTerminalEvents]: "terminal",
[WS_METHODS.subscribeTerminalMetadata]: "terminal",
[WS_METHODS.previewOpen]: "preview",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ const layerTerminal = TerminalManager.layer.pipe(
Layer.provide(layerPtyAdapter),
Layer.provide(layerPortScanner),
Layer.provide(layerNativeTelemetry),
Layer.provide(ProjectionStoreV2.layer),
);

const layerPreview = Layer.empty.pipe(
Expand Down
Loading
Loading