feat: terminal reattach, explorer tab memory, worktree terminals
This commit is contained in:
@@ -103,7 +103,7 @@ export function AgentStreamView({
|
||||
const streamItemCountRef = useRef(0);
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(new Set());
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -154,7 +154,12 @@ export function AgentStreamView({
|
||||
requestFilePreview(agentId, normalized.file);
|
||||
}
|
||||
|
||||
setExplorerTab("files");
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
tab: "files",
|
||||
});
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
@@ -163,7 +168,7 @@ export function AgentStreamView({
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
selectExplorerEntry,
|
||||
setExplorerTab,
|
||||
setExplorerTabForCheckout,
|
||||
openFileExplorer,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { TerminalPane } from "./terminal-pane";
|
||||
@@ -40,9 +39,10 @@ interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps) {
|
||||
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile =
|
||||
@@ -52,7 +52,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
@@ -102,9 +102,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setExplorerTab(tab);
|
||||
setExplorerTabForCheckout({ serverId, cwd, isGit, tab });
|
||||
},
|
||||
[setExplorerTab]
|
||||
[cwd, isGit, serverId, setExplorerTabForCheckout]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -268,6 +268,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -300,6 +301,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
cwd={cwd}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -313,6 +315,7 @@ interface SidebarContentProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
@@ -323,19 +326,12 @@ function SidebarContent({
|
||||
serverId,
|
||||
agentId,
|
||||
cwd,
|
||||
isGit,
|
||||
isMobile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { status } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const hasResolvedCheckoutStatus = status !== null;
|
||||
|
||||
// Switch to Files tab if Changes tab is hidden and user was on it
|
||||
useEffect(() => {
|
||||
if (hasResolvedCheckoutStatus && !isGit && activeTab === "changes") {
|
||||
onTabPress("files");
|
||||
}
|
||||
}, [hasResolvedCheckoutStatus, isGit, activeTab, onTabPress]);
|
||||
const resolvedTab: ExplorerTab =
|
||||
!isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -345,13 +341,13 @@ function SidebarContent({
|
||||
{isGit && (
|
||||
<Pressable
|
||||
testID="explorer-tab-changes"
|
||||
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
|
||||
onPress={() => onTabPress("changes")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "changes" && styles.tabTextActive,
|
||||
resolvedTab === "changes" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Changes
|
||||
@@ -360,13 +356,13 @@ function SidebarContent({
|
||||
)}
|
||||
<Pressable
|
||||
testID="explorer-tab-files"
|
||||
style={[styles.tab, activeTab === "files" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
|
||||
onPress={() => onTabPress("files")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "files" && styles.tabTextActive,
|
||||
resolvedTab === "files" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Files
|
||||
@@ -374,13 +370,13 @@ function SidebarContent({
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="explorer-tab-terminals"
|
||||
style={[styles.tab, activeTab === "terminals" && styles.tabActive]}
|
||||
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
|
||||
onPress={() => onTabPress("terminals")}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.tabText,
|
||||
activeTab === "terminals" && styles.tabTextActive,
|
||||
resolvedTab === "terminals" && styles.tabTextActive,
|
||||
]}
|
||||
>
|
||||
Terminals
|
||||
@@ -398,13 +394,13 @@ function SidebarContent({
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{activeTab === "changes" && (
|
||||
{resolvedTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
{resolvedTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
{activeTab === "terminals" && (
|
||||
{resolvedTab === "terminals" && (
|
||||
<TerminalPane serverId={serverId} cwd={cwd} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -5,92 +5,8 @@ import { getIsTauri } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
type ShortcutHelpRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type ShortcutHelpSection = {
|
||||
title: string;
|
||||
rows: ShortcutHelpRow[];
|
||||
};
|
||||
|
||||
function buildShortcutHelpSections(input: {
|
||||
isMac: boolean;
|
||||
isTauri: boolean;
|
||||
}): ShortcutHelpSection[] {
|
||||
const leftSidebarKeys: ShortcutKey[] = input.isMac ? ["mod", "B"] : ["mod", "."];
|
||||
const newAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "N"]
|
||||
: ["mod", "alt", "N"];
|
||||
const quickAgentKeys: ShortcutKey[] = input.isTauri
|
||||
? ["mod", "1-9"]
|
||||
: ["alt", "1-9"];
|
||||
|
||||
return [
|
||||
{
|
||||
title: "Global",
|
||||
rows: [
|
||||
{
|
||||
id: "show-shortcuts",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
},
|
||||
{
|
||||
id: "toggle-left-sidebar",
|
||||
label: "Toggle left sidebar",
|
||||
keys: leftSidebarKeys,
|
||||
},
|
||||
{
|
||||
id: "toggle-right-sidebar",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
{
|
||||
id: "toggle-command-center",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
{
|
||||
id: "new-agent",
|
||||
label: "Create new agent",
|
||||
keys: newAgentKeys,
|
||||
},
|
||||
{
|
||||
id: "quick-open-agent",
|
||||
label: "Open sidebar agent shortcut",
|
||||
keys: quickAgentKeys,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Agent Input",
|
||||
rows: [
|
||||
{
|
||||
id: "dictation-toggle",
|
||||
label: "Start/stop dictation",
|
||||
keys: ["mod", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-toggle",
|
||||
label: "Toggle voice mode",
|
||||
keys: ["mod", "shift", "D"],
|
||||
},
|
||||
{
|
||||
id: "voice-mute-toggle",
|
||||
label: "Mute/unmute voice mode",
|
||||
keys: ["Space"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
@@ -99,7 +15,7 @@ export function KeyboardShortcutsDialog() {
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isTauri = getIsTauri();
|
||||
const sections = useMemo(
|
||||
() => buildShortcutHelpSections({ isMac, isTauri }),
|
||||
() => buildKeyboardShortcutHelpSections({ isMac, isTauri }),
|
||||
[isMac, isTauri]
|
||||
);
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ export default function TerminalEmulator({
|
||||
fitAndEmitResize();
|
||||
});
|
||||
resizeObserver.observe(root);
|
||||
resizeObserver.observe(host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize();
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
@@ -378,6 +379,25 @@ export default function TerminalEmulator({
|
||||
fitAndEmitResize();
|
||||
}, 250);
|
||||
|
||||
const fitTimeoutHandles = [0, 16, 48, 120, 250, 500, 1_000, 2_000].map((delay) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
}, delay)
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// no-op
|
||||
});
|
||||
|
||||
window.setTimeout(() => fitAndEmitResize(true), 0);
|
||||
|
||||
if (outputText.length > 0) {
|
||||
@@ -391,6 +411,10 @@ export default function TerminalEmulator({
|
||||
window.removeEventListener("resize", windowResizeHandler);
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
window.clearInterval(fitInterval);
|
||||
for (const handle of fitTimeoutHandles) {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
|
||||
root.removeEventListener("touchstart", rootTouchStartHandler);
|
||||
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
||||
root.removeEventListener("touchend", rootTouchEndHandler);
|
||||
@@ -478,12 +502,10 @@ export default function TerminalEmulator({
|
||||
ref={rootRef}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
backgroundColor,
|
||||
|
||||
@@ -18,9 +18,17 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
mapTerminalDataToKey,
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import {
|
||||
getTerminalResumeOffset,
|
||||
getTerminalAttachRetryDelayMs,
|
||||
isTerminalAttachRetryableError,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
} from "@/utils/terminal-attach";
|
||||
import TerminalEmulator from "./terminal-emulator";
|
||||
|
||||
interface TerminalPaneProps {
|
||||
@@ -30,6 +38,8 @@ interface TerminalPaneProps {
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_ATTACH_MAX_ATTEMPTS = 4;
|
||||
const TERMINAL_ATTACH_TIMEOUT_MS = 12_000;
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
ctrl: "Ctrl",
|
||||
@@ -61,8 +71,8 @@ const EMPTY_MODIFIERS: ModifierState = {
|
||||
alt: false,
|
||||
};
|
||||
|
||||
function terminalScopeKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd}`;
|
||||
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
|
||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
||||
@@ -99,9 +109,10 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey(serverId, cwd), [serverId, cwd]);
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const selectedTerminalByScopeRef = useRef<Map<string, string>>(new Map());
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const resumeOffsetByTerminalIdRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null);
|
||||
const [outputByTerminalId, setOutputByTerminalId] = useState<Map<string, string>>(
|
||||
@@ -111,6 +122,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(null);
|
||||
const [attachGeneration, setAttachGeneration] = useState(0);
|
||||
const [isAttaching, setIsAttaching] = useState(false);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
@@ -121,11 +133,19 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const activeStreamRef = useRef<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
} | null>(activeStream);
|
||||
|
||||
useEffect(() => {
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
activeStreamRef.current = activeStream;
|
||||
}, [activeStream]);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
return;
|
||||
@@ -201,14 +221,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTerminalIdRef.current === exitedTerminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === exitedTerminalId ? null : current
|
||||
);
|
||||
const activeStreamForTerminal = activeStreamRef.current;
|
||||
if (
|
||||
selectedTerminalIdRef.current === exitedTerminalId &&
|
||||
activeStreamForTerminal?.terminalId === exitedTerminalId &&
|
||||
activeStreamForTerminal.streamId === message.payload.streamId
|
||||
) {
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === exitedTerminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
setStreamError("Terminal stream ended. Reconnecting…");
|
||||
setIsAttaching(true);
|
||||
setAttachGeneration((current) => current + 1);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
|
||||
@@ -253,6 +277,7 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
},
|
||||
onSuccess: (_, terminalId) => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
setSelectedTerminalId((current) =>
|
||||
current === terminalId ? null : current
|
||||
@@ -310,6 +335,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
}, [scopeKey, terminals, selectedTerminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminals.length === 0) {
|
||||
resumeOffsetByTerminalIdRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIdSet = new Set(terminals.map((terminal) => terminal.id));
|
||||
for (const terminalId of Array.from(resumeOffsetByTerminalIdRef.current.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
resumeOffsetByTerminalIdRef.current.delete(terminalId);
|
||||
}
|
||||
}
|
||||
}, [terminals]);
|
||||
|
||||
const appendOutput = useCallback((terminalId: string, text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
@@ -343,40 +382,102 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
|
||||
setIsAttaching(true);
|
||||
setStreamError(null);
|
||||
lastReportedSizeRef.current = null;
|
||||
|
||||
const attach = async () => {
|
||||
try {
|
||||
const attachPayload = await client.attachTerminalStream(terminalId);
|
||||
if (isCancelled) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let lastErrorMessage = "Unable to attach terminal stream";
|
||||
const totalAttempts = TERMINAL_ATTACH_MAX_ATTEMPTS;
|
||||
for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
|
||||
try {
|
||||
const lastSize = lastReportedSizeRef.current;
|
||||
const resumeOffset = getTerminalResumeOffset({
|
||||
terminalId,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const attachPayload = await withPromiseTimeout({
|
||||
promise: client.attachTerminalStream(terminalId, {
|
||||
...(resumeOffset !== undefined ? { resumeOffset } : {}),
|
||||
...(lastSize
|
||||
? {
|
||||
rows: lastSize.rows,
|
||||
cols: lastSize.cols,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
timeoutMs: TERMINAL_ATTACH_TIMEOUT_MS,
|
||||
timeoutMessage: "Timed out attaching terminal stream",
|
||||
});
|
||||
if (isCancelled) {
|
||||
if (typeof attachPayload.streamId === "number") {
|
||||
void client.detachTerminalStream(attachPayload.streamId).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
setStreamError(attachPayload.error ?? "Unable to attach terminal stream");
|
||||
setActiveStream(null);
|
||||
return;
|
||||
}
|
||||
if (attachPayload.error || typeof attachPayload.streamId !== "number") {
|
||||
lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
return;
|
||||
}
|
||||
|
||||
streamId = attachPayload.streamId;
|
||||
decoder = new TextDecoder();
|
||||
setActiveStream({ terminalId, streamId });
|
||||
streamId = attachPayload.streamId;
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
decoder = new TextDecoder();
|
||||
setActiveStream({ terminalId, streamId });
|
||||
setStreamError(null);
|
||||
|
||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (isCancelled) {
|
||||
unsubscribe = client.onTerminalStreamData(streamId, (chunk) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
updateTerminalResumeOffset({
|
||||
terminalId,
|
||||
offset: chunk.endOffset,
|
||||
resumeOffsetByTerminalId: resumeOffsetByTerminalIdRef.current,
|
||||
});
|
||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
||||
appendOutput(terminalId, text);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastErrorMessage =
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream";
|
||||
const hasRemainingAttempts = attempt < totalAttempts - 1;
|
||||
if (
|
||||
hasRemainingAttempts &&
|
||||
isTerminalAttachRetryableError({ message: lastErrorMessage })
|
||||
) {
|
||||
await waitForDuration({
|
||||
durationMs: getTerminalAttachRetryDelayMs({ attempt }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = decoder?.decode(chunk.data, { stream: true }) ?? "";
|
||||
appendOutput(terminalId, text);
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
if (!isCancelled) {
|
||||
setStreamError(
|
||||
error instanceof Error ? error.message : "Unable to attach terminal stream"
|
||||
);
|
||||
setStreamError(lastErrorMessage);
|
||||
setActiveStream(null);
|
||||
}
|
||||
} finally {
|
||||
@@ -401,8 +502,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
if (streamId !== null) {
|
||||
void client.detachTerminalStream(streamId).catch(() => {});
|
||||
}
|
||||
setActiveStream((current) =>
|
||||
current?.terminalId === terminalId ? null : current
|
||||
);
|
||||
setIsAttaching(false);
|
||||
};
|
||||
}, [appendOutput, client, isConnected, selectedTerminalId]);
|
||||
}, [appendOutput, attachGeneration, client, isConnected, selectedTerminalId]);
|
||||
|
||||
const activeStreamId =
|
||||
activeStream && activeStream.terminalId === selectedTerminalId
|
||||
@@ -477,21 +582,27 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
}
|
||||
|
||||
if (hasPendingTerminalModifiers(modifiers)) {
|
||||
const mappedKey = mapTerminalDataToKey(data);
|
||||
if (
|
||||
mappedKey &&
|
||||
sendTerminalKey(
|
||||
{
|
||||
key: mappedKey,
|
||||
const pendingResolution = resolvePendingModifierDataInput({
|
||||
data,
|
||||
pendingModifiers: modifiers,
|
||||
});
|
||||
if (pendingResolution.mode === "key") {
|
||||
if (
|
||||
sendTerminalKey({
|
||||
key: pendingResolution.key,
|
||||
ctrl: modifiers.ctrl,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
meta: false,
|
||||
}
|
||||
)
|
||||
) {
|
||||
})
|
||||
) {
|
||||
clearPendingModifiers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingResolution.clearPendingModifiers) {
|
||||
clearPendingModifiers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,9 +855,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||
)}
|
||||
|
||||
{isAttaching ? (
|
||||
<View style={styles.attachOverlay}>
|
||||
<View style={styles.attachOverlay} testID="terminal-attach-loading">
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.attachOverlayText}>Attaching terminal…</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -912,22 +1022,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
attachOverlay: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
right: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
attachOverlayText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
justifyContent: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.16)",
|
||||
},
|
||||
errorRow: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
Reference in New Issue
Block a user