Reuse terminals for non-service scripts and track lifecycle via OSC 633
Scripts launched from the UI now run in a dedicated terminal per script (titled after the script) and reuse the same terminal across re-runs. Script lifecycle is driven by a new onCommandFinished signal sourced from OSC 633 command-finished events emitted by the zsh shell integration, so foreground script exit updates status without killing the terminal. Script payloads now carry terminalId so the workspace can surface the associated tab from the scripts dropdown.
This commit is contained in:
@@ -62,6 +62,7 @@ const runningScript: WorkspaceScriptPayload = {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
};
|
||||
|
||||
function workspace(input: {
|
||||
|
||||
@@ -32,6 +32,7 @@ const runningScript: WorkspaceScriptPayload = {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
};
|
||||
|
||||
describe("patchWorkspaceScripts", () => {
|
||||
|
||||
@@ -697,6 +697,55 @@ function WorkspaceScreenContent({
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || terminalsQuery.dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
});
|
||||
}, [liveTerminalIds, terminalsQuery.dataUpdatedAt]);
|
||||
const knownTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(liveTerminalIds);
|
||||
for (const terminalId of pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}, [liveTerminalIds, pendingScriptTerminalIds]);
|
||||
const scriptTerminalIds = useMemo(() => {
|
||||
const terminalIds = new Set(pendingScriptTerminalIds.keys());
|
||||
for (const script of workspaceDescriptor?.scripts ?? []) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() =>
|
||||
terminals
|
||||
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async (input?: { paneId?: string }) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
@@ -948,6 +997,38 @@ function WorkspaceScreenContent({
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
if (persistenceKey) {
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||
},
|
||||
[
|
||||
openWorkspaceTabFocused,
|
||||
persistenceKey,
|
||||
queryClient,
|
||||
terminalsQuery.dataUpdatedAt,
|
||||
terminalsQueryKey,
|
||||
],
|
||||
);
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||
},
|
||||
[openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
@@ -1065,7 +1146,8 @@ function WorkspaceScreenContent({
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
|
||||
knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
|
||||
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
});
|
||||
}, [
|
||||
@@ -1074,7 +1156,8 @@ function WorkspaceScreenContent({
|
||||
pendingByDraftId,
|
||||
persistenceKey,
|
||||
reconcileWorkspaceTabs,
|
||||
terminals,
|
||||
knownTerminalIds,
|
||||
standaloneTerminalIds,
|
||||
terminalsQuery.isSuccess,
|
||||
uiTabs,
|
||||
workspaceAgentVisibility,
|
||||
@@ -2195,6 +2278,9 @@ function WorkspaceScreenContent({
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
scripts={workspaceDescriptor.scripts}
|
||||
liveTerminalIds={liveTerminalIds}
|
||||
onScriptTerminalStarted={handleScriptTerminalStarted}
|
||||
onViewTerminal={handleViewScriptTerminal}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactElement } from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
|
||||
const { theme, startWorkspaceScriptMock } = vi.hoisted(() => {
|
||||
const theme = {
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { normal: "400", medium: "500" },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
surface2: "#222",
|
||||
borderAccent: "#444",
|
||||
palette: {
|
||||
blue: { 500: "#0a84ff" },
|
||||
green: { 500: "#30d158" },
|
||||
red: { 300: "#ff9f99", 500: "#ff453a" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isNative: false,
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeSnapshot: () => ({ activeConnection: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store", () => ({
|
||||
useSessionStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
sessions: {
|
||||
"test-server": {
|
||||
client: {
|
||||
startWorkspaceScript: startWorkspaceScriptMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ show: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<div data-testid={testID}>{children}</div>
|
||||
),
|
||||
DropdownMenuSeparator: () => <div role="separator" />,
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<button type="button" data-testid={testID}>
|
||||
{typeof children === "function"
|
||||
? children({ hovered: false, pressed: false, open: true })
|
||||
: children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", {
|
||||
"data-icon": name,
|
||||
"data-color": props.color,
|
||||
"data-size": props.size,
|
||||
"data-testid": props.testID,
|
||||
});
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ExternalLink: createIcon("ExternalLink"),
|
||||
Globe: createIcon("Globe"),
|
||||
Play: createIcon("Play"),
|
||||
SquareTerminal: createIcon("SquareTerminal"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
||||
return actual;
|
||||
});
|
||||
|
||||
function script(
|
||||
input: Partial<WorkspaceScriptPayload> & Pick<WorkspaceScriptPayload, "scriptName">,
|
||||
): WorkspaceScriptPayload {
|
||||
return {
|
||||
scriptName: input.scriptName,
|
||||
type: input.type ?? "script",
|
||||
hostname: input.hostname ?? input.scriptName,
|
||||
port: input.port ?? null,
|
||||
proxyUrl: input.proxyUrl ?? null,
|
||||
lifecycle: input.lifecycle ?? "stopped",
|
||||
health: input.health ?? null,
|
||||
exitCode: input.exitCode ?? null,
|
||||
terminalId: input.terminalId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderScripts(scripts: WorkspaceScriptPayload[]): {
|
||||
rerender: (nextScripts: WorkspaceScriptPayload[]) => Promise<void>;
|
||||
unmount: () => void;
|
||||
} {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceScriptsButton
|
||||
serverId="test-server"
|
||||
workspaceId="workspace-1"
|
||||
scripts={nextScripts}
|
||||
liveTerminalIds={["terminal-script-1"]}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(element(scripts));
|
||||
});
|
||||
|
||||
return {
|
||||
rerender: async (nextScripts) => {
|
||||
await act(async () => {
|
||||
root.render(element(nextScripts));
|
||||
});
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRow(scriptName: string): HTMLElement {
|
||||
const row = document.querySelector(`[data-testid="workspace-scripts-item-${scriptName}"]`);
|
||||
if (!(row instanceof HTMLElement)) {
|
||||
throw new Error(`Missing script row for ${scriptName}`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function requirePrimaryIcon(row: HTMLElement): HTMLElement {
|
||||
const icon = row.querySelector("[data-icon]");
|
||||
if (!(icon instanceof HTMLElement)) {
|
||||
throw new Error("Missing row icon");
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
describe("WorkspaceScriptsButton", () => {
|
||||
let current: ReturnType<typeof renderScripts> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
document.body.innerHTML = "";
|
||||
startWorkspaceScriptMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
current?.unmount();
|
||||
current = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps completed script row icons visible and muted while the menu content stays mounted", async () => {
|
||||
current = renderScripts([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "running",
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
let row = requireRow("typecheck");
|
||||
let icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.palette.blue[500]);
|
||||
|
||||
await current.rerender([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 0,
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
row = requireRow("typecheck");
|
||||
icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
|
||||
expect(row.textContent).toContain("typecheck");
|
||||
expect(row.textContent).toContain("exit 0");
|
||||
expect(row.textContent).toContain("Run");
|
||||
|
||||
await current.rerender([
|
||||
script({
|
||||
scriptName: "typecheck",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 7,
|
||||
terminalId: "terminal-script-1",
|
||||
}),
|
||||
]);
|
||||
|
||||
row = requireRow("typecheck");
|
||||
icon = requirePrimaryIcon(row);
|
||||
expect(icon.dataset.icon).toBe("SquareTerminal");
|
||||
expect(icon.dataset.color).toBe(theme.colors.foregroundMuted);
|
||||
expect(row.textContent).toContain("exit 7");
|
||||
expect(row.textContent).toContain("Run");
|
||||
});
|
||||
|
||||
it("uses service icon color for service health and running unknown status only", () => {
|
||||
current = renderScripts([
|
||||
script({
|
||||
scriptName: "web",
|
||||
type: "service",
|
||||
hostname: "web.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
port: 3000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "api",
|
||||
type: "service",
|
||||
hostname: "api.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: "unhealthy",
|
||||
port: 4000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "worker",
|
||||
type: "service",
|
||||
hostname: "worker.paseo.localhost",
|
||||
lifecycle: "running",
|
||||
health: null,
|
||||
port: 5000,
|
||||
}),
|
||||
script({
|
||||
scriptName: "old-service",
|
||||
type: "service",
|
||||
hostname: "old-service.paseo.localhost",
|
||||
lifecycle: "stopped",
|
||||
exitCode: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(requirePrimaryIcon(requireRow("web")).dataset.color).toBe(
|
||||
theme.colors.palette.green[500],
|
||||
);
|
||||
expect(requirePrimaryIcon(requireRow("api")).dataset.color).toBe(theme.colors.palette.red[500]);
|
||||
expect(requirePrimaryIcon(requireRow("worker")).dataset.color).toBe(
|
||||
theme.colors.palette.blue[500],
|
||||
);
|
||||
expect(requirePrimaryIcon(requireRow("old-service")).dataset.color).toBe(
|
||||
theme.colors.foregroundMuted,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,7 @@
|
||||
import { type ReactElement } from "react";
|
||||
import { Fragment, useMemo, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
LoaderCircle,
|
||||
Play,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -16,44 +9,153 @@ import { useHostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links";
|
||||
|
||||
type Script = WorkspaceDescriptor["scripts"][number];
|
||||
|
||||
function getScriptHealthColor(
|
||||
health: Script["health"],
|
||||
theme: ReturnType<typeof useUnistyles>["theme"],
|
||||
): string {
|
||||
if (health === "healthy") {
|
||||
return theme.colors.palette.blue[500];
|
||||
}
|
||||
if (health === "unhealthy") {
|
||||
return theme.colors.palette.red[500];
|
||||
}
|
||||
return theme.colors.foregroundMuted;
|
||||
}
|
||||
type ScriptActionIcon = "start" | "view";
|
||||
|
||||
interface WorkspaceScriptsButtonProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
scripts: WorkspaceDescriptor["scripts"];
|
||||
liveTerminalIds?: readonly string[];
|
||||
onScriptTerminalStarted?: (terminalId: string) => void;
|
||||
onViewTerminal?: (terminalId: string) => void;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
interface ScriptActionButtonProps {
|
||||
accessibilityLabel: string;
|
||||
disabled?: boolean;
|
||||
icon: ScriptActionIcon;
|
||||
label: string;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
function ScriptActionButton({
|
||||
accessibilityLabel,
|
||||
disabled,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
testID,
|
||||
}: ScriptActionButtonProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
hitSlop={4}
|
||||
disabled={disabled}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
onPress();
|
||||
}}
|
||||
style={styles.actionButton}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
const iconProps = { size: 10, color };
|
||||
let iconElement: ReactElement;
|
||||
if (icon === "view") {
|
||||
iconElement = <SquareTerminal {...iconProps} />;
|
||||
} else {
|
||||
iconElement = <Play {...iconProps} fill="transparent" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{iconElement}
|
||||
<Text style={[styles.actionButtonLabel, { color }]}>{label}</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function stripUrlProtocol(url: string): string {
|
||||
return url.replace(/^https?:\/\//, "");
|
||||
}
|
||||
|
||||
interface HostLinkProps {
|
||||
label: string;
|
||||
url: string | null;
|
||||
scriptName: string;
|
||||
}
|
||||
|
||||
function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const disabled = !url;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`Open ${scriptName} at ${label}`}
|
||||
disabled={disabled}
|
||||
hitSlop={2}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
if (url) void openExternalUrl(url);
|
||||
}}
|
||||
style={styles.hostRow}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const showIcon = !disabled && (hovered || isNative);
|
||||
const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<Text style={[styles.hostLabel, { color }]} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.hostIconSlot}>
|
||||
{showIcon ? <ExternalLink size={10} color={color} /> : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ExitCodeBadge({ code }: { code: number }): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300];
|
||||
return (
|
||||
<View style={styles.exitBadge}>
|
||||
<Text style={[styles.exitBadgeText, { color }]}>exit {code}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface HostLink {
|
||||
key: string;
|
||||
label: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export function WorkspaceScriptsButton({
|
||||
serverId,
|
||||
workspaceId,
|
||||
scripts,
|
||||
liveTerminalIds = [],
|
||||
onScriptTerminalStarted,
|
||||
onViewTerminal,
|
||||
hideLabels,
|
||||
}: WorkspaceScriptsButtonProps): ReactElement | null {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null;
|
||||
const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]);
|
||||
|
||||
const startScriptMutation = useMutation({
|
||||
mutationFn: async (scriptName: string) => {
|
||||
@@ -71,6 +173,11 @@ export function WorkspaceScriptsButton({
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.terminalId) {
|
||||
onScriptTerminalStarted?.(result.terminalId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (scripts.length === 0) {
|
||||
@@ -111,50 +218,90 @@ export function WorkspaceScriptsButton({
|
||||
testID="workspace-scripts-menu"
|
||||
>
|
||||
<View style={styles.scriptList}>
|
||||
{scripts.map((script) => {
|
||||
{scripts.map((script, index) => {
|
||||
const isRunning = script.lifecycle === "running";
|
||||
const isService = (script.type ?? "service") === "service";
|
||||
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
|
||||
const isLinkable = isService && isRunning && !!serviceLink.openUrl;
|
||||
const exitCode = script.exitCode ?? null;
|
||||
const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection });
|
||||
const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null;
|
||||
const liveTerminalId =
|
||||
script.terminalId && liveTerminalIdSet.has(script.terminalId)
|
||||
? script.terminalId
|
||||
: null;
|
||||
|
||||
let dotColor: string;
|
||||
const hostLinks: HostLink[] = [];
|
||||
if (isService && isRunning) {
|
||||
const routedUrl = script.proxyUrl ?? serviceLink.labelUrl;
|
||||
if (routedUrl) {
|
||||
hostLinks.push({
|
||||
key: "proxy",
|
||||
label: stripUrlProtocol(routedUrl),
|
||||
url: serviceOpenUrl,
|
||||
});
|
||||
}
|
||||
if (script.port !== null) {
|
||||
const localhostLabel = `localhost:${script.port}`;
|
||||
const alreadyShown = hostLinks.some((l) => l.label === localhostLabel);
|
||||
if (!alreadyShown) {
|
||||
hostLinks.push({
|
||||
key: "localhost",
|
||||
label: localhostLabel,
|
||||
url: `http://localhost:${script.port}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let iconColor = theme.colors.foregroundMuted;
|
||||
if (isService) {
|
||||
dotColor = isRunning
|
||||
? getScriptHealthColor(script.health, theme)
|
||||
: theme.colors.foregroundMuted;
|
||||
if (isRunning && script.health === "healthy") {
|
||||
iconColor = theme.colors.palette.green[500];
|
||||
} else if (isRunning && script.health === "unhealthy") {
|
||||
iconColor = theme.colors.palette.red[500];
|
||||
} else if (isRunning) {
|
||||
iconColor = theme.colors.palette.blue[500];
|
||||
}
|
||||
} else if (isRunning) {
|
||||
dotColor = theme.colors.palette.blue[500];
|
||||
} else if (exitCode === 0) {
|
||||
dotColor = theme.colors.palette.green[500];
|
||||
} else if (exitCode !== null) {
|
||||
dotColor = theme.colors.palette.red[500];
|
||||
} else {
|
||||
dotColor = theme.colors.foregroundMuted;
|
||||
iconColor = theme.colors.palette.blue[500];
|
||||
}
|
||||
|
||||
const ScriptIcon = isService ? Globe : SquareTerminal;
|
||||
const showExitBadge = !isRunning && exitCode !== null;
|
||||
|
||||
let primaryAction: ReactElement | null = null;
|
||||
if (isRunning && liveTerminalId) {
|
||||
primaryAction = (
|
||||
<ScriptActionButton
|
||||
accessibilityLabel={`View ${script.scriptName} terminal`}
|
||||
testID={`workspace-scripts-view-${script.scriptName}`}
|
||||
icon="view"
|
||||
label="View"
|
||||
onPress={() => onViewTerminal?.(liveTerminalId)}
|
||||
/>
|
||||
);
|
||||
} else if (!isRunning) {
|
||||
primaryAction = (
|
||||
<ScriptActionButton
|
||||
accessibilityLabel={`Run ${script.scriptName} script`}
|
||||
testID={`workspace-scripts-start-${script.scriptName}`}
|
||||
disabled={startScriptMutation.isPending}
|
||||
icon="start"
|
||||
label="Run"
|
||||
onPress={() => startScriptMutation.mutate(script.scriptName)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={script.scriptName}
|
||||
testID={`workspace-scripts-item-${script.scriptName}`}
|
||||
accessibilityRole={isLinkable ? "link" : undefined}
|
||||
accessibilityLabel={`${script.scriptName} script`}
|
||||
style={({ hovered }) => [
|
||||
styles.scriptRow,
|
||||
hovered && isLinkable && styles.scriptRowHovered,
|
||||
]}
|
||||
onPress={
|
||||
isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined
|
||||
}
|
||||
disabled={!isLinkable}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
{isService ? (
|
||||
<Globe size={14} color={dotColor} style={styles.scriptIcon} />
|
||||
) : (
|
||||
<SquareTerminal size={14} color={dotColor} style={styles.scriptIcon} />
|
||||
)}
|
||||
<Fragment key={script.scriptName}>
|
||||
{index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<View
|
||||
testID={`workspace-scripts-item-${script.scriptName}`}
|
||||
accessibilityLabel={`${script.scriptName} script`}
|
||||
style={styles.scriptItem}
|
||||
>
|
||||
<View style={styles.scriptHeader}>
|
||||
<ScriptIcon size={14} color={iconColor} style={styles.scriptIcon} />
|
||||
<Text
|
||||
style={[
|
||||
styles.scriptName,
|
||||
@@ -168,78 +315,24 @@ export function WorkspaceScriptsButton({
|
||||
>
|
||||
{script.scriptName}
|
||||
</Text>
|
||||
{isService && isRunning && serviceLink.labelUrl ? (
|
||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||
{serviceLink.labelUrl.replace(/^https?:\/\//, "")}
|
||||
</Text>
|
||||
) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? (
|
||||
<Text style={styles.scriptUrl} numberOfLines={1}>
|
||||
exit {exitCode}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.spacer} />
|
||||
)}
|
||||
{isRunning ? (
|
||||
isLinkable && hovered ? (
|
||||
<View
|
||||
style={[
|
||||
styles.externalLinkOverlay,
|
||||
{
|
||||
backgroundImage: `linear-gradient(to right, transparent, ${theme.colors.surface2} 40%)`,
|
||||
} as any,
|
||||
]}
|
||||
>
|
||||
<ExternalLink size={12} color={theme.colors.foreground} />
|
||||
</View>
|
||||
) : null
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Run ${script.scriptName} script`}
|
||||
testID={`workspace-scripts-start-${script.scriptName}`}
|
||||
hitSlop={4}
|
||||
disabled={startScriptMutation.isPending}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
startScriptMutation.mutate(script.scriptName);
|
||||
}}
|
||||
style={styles.startButton}
|
||||
>
|
||||
{({ hovered: actionHovered }) =>
|
||||
startScriptMutation.isPending &&
|
||||
startScriptMutation.variables === script.scriptName ? (
|
||||
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<>
|
||||
<Play
|
||||
size={10}
|
||||
color={
|
||||
actionHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
fill="transparent"
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.startButtonLabel,
|
||||
{
|
||||
color: actionHovered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Run
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
{showExitBadge ? <ExitCodeBadge code={exitCode} /> : null}
|
||||
<View style={styles.spacer} />
|
||||
{primaryAction}
|
||||
</View>
|
||||
{hostLinks.length > 0 ? (
|
||||
<View style={styles.hostList}>
|
||||
{hostLinks.map((link) => (
|
||||
<HostLinkRow
|
||||
key={link.key}
|
||||
label={link.label}
|
||||
url={link.url}
|
||||
scriptName={script.scriptName}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
@@ -270,11 +363,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
},
|
||||
splitButtonPrimaryIconOnly: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
justifyContent: "center",
|
||||
},
|
||||
splitButtonPrimaryHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -293,16 +381,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
scriptList: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
scriptRow: {
|
||||
scriptItem: {
|
||||
paddingVertical: 6,
|
||||
},
|
||||
scriptHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: 6,
|
||||
minHeight: 28,
|
||||
},
|
||||
scriptRowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
minHeight: 24,
|
||||
},
|
||||
scriptIcon: {
|
||||
flexShrink: 0,
|
||||
@@ -311,36 +398,53 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
flexShrink: 0,
|
||||
},
|
||||
scriptUrl: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 18,
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
textAlign: "right",
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
externalLinkOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
paddingLeft: theme.spacing[4],
|
||||
paddingRight: theme.spacing[3],
|
||||
hostList: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
hostRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
paddingVertical: 2,
|
||||
minHeight: 18,
|
||||
},
|
||||
hostLabel: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
lineHeight: 14,
|
||||
},
|
||||
hostIconSlot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
startButton: {
|
||||
exitBadge: {
|
||||
paddingHorizontal: theme.spacing[1.5],
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
exitBadgeText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 12,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
startButtonLabel: {
|
||||
actionButtonLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
|
||||
@@ -61,6 +61,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
lifecycle: "running" as const,
|
||||
health: "healthy" as const,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
];
|
||||
const workspace = normalizeWorkspaceDescriptor({
|
||||
@@ -88,6 +89,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
]);
|
||||
expect(workspace.scripts).not.toBe(scripts);
|
||||
@@ -137,6 +139,7 @@ describe("mergeWorkspaces", () => {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -152,6 +155,7 @@ describe("mergeWorkspaces", () => {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -263,6 +267,7 @@ describe("patchWorkspaceScripts", () => {
|
||||
lifecycle: "running" as const,
|
||||
health: "healthy" as const,
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
};
|
||||
const workspace = createWorkspace({ id: "/repo/main", scripts: [script] });
|
||||
const current = new Map([[workspace.id, workspace]]);
|
||||
|
||||
@@ -203,6 +203,7 @@ export interface WorkspaceTabSnapshot {
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
knownAgentIds: Iterable<string>;
|
||||
knownTerminalIds?: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate?: boolean;
|
||||
}
|
||||
@@ -1454,6 +1455,9 @@ export function reconcileWorkspaceTabs(
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const knownTerminalIds = snapshot.knownTerminalIds
|
||||
? normalizeStringSet(snapshot.knownTerminalIds)
|
||||
: standaloneTerminalIds;
|
||||
const visibleAgentIds = new Set(activeAgentIds);
|
||||
for (const agentId of pinnedAgentIds) {
|
||||
if (knownAgentIds.has(agentId)) {
|
||||
@@ -1537,7 +1541,7 @@ export function reconcileWorkspaceTabs(
|
||||
if (
|
||||
isTerminalTab(tab) &&
|
||||
snapshot.terminalsHydrated &&
|
||||
!standaloneTerminalIds.has(tab.target.terminalId)
|
||||
!knownTerminalIds.has(tab.target.terminalId)
|
||||
) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
|
||||
@@ -1107,6 +1107,47 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const scriptTabId = store.openTabFocused(workspaceKey, {
|
||||
kind: "terminal",
|
||||
terminalId: "term-script",
|
||||
});
|
||||
|
||||
store.reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
knownTerminalIds: ["term-script", "term-manual"],
|
||||
standaloneTerminalIds: ["term-manual"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const tabs = useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
||||
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
||||
});
|
||||
|
||||
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: [],
|
||||
knownAgentIds: [],
|
||||
knownTerminalIds: ["term-script"],
|
||||
standaloneTerminalIds: [],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]);
|
||||
});
|
||||
|
||||
it("explicitly opening an agent tab clears hidden intent", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
@@ -12,6 +12,7 @@ const runningService: WorkspaceScriptPayload = {
|
||||
lifecycle: "running",
|
||||
health: "healthy",
|
||||
exitCode: null,
|
||||
terminalId: null,
|
||||
};
|
||||
|
||||
function resolveLink(activeConnection: ActiveConnection | null) {
|
||||
|
||||
Reference in New Issue
Block a user