6fa38cd62c
* refactor(backend): re-key user-shell RPCs to task_environment_id user_shell.list/create/stop now take task_environment_id directly instead of session_id with a session→env translation. Sessions in the same task share one shell list, so the env is the natural scope. HTTP route /api/v1/sessions/:id/terminals → /api/v1/environments/:id/terminals. shell.status/subscribe/input (agent passthrough) stay session-keyed. * fix(backend): heal task_sessions.task_environment_id on boot Sessions created via paths that didn't write the FK left shell ops broken — every env-keyed user-shell RPC failed to resolve the env and the frontend silently swallowed the error. The boot heal links each orphan session to its task's existing env (idempotent, runs after backfillTaskEnvironments + healDuplicateTaskEnvironments). * refactor(web): drop session_id from user-shell API, hooks, and slice createUserShell/stopUserShell/fetchTerminals take environmentId. useUserShells takes envId directly (no environmentIdBySessionId translation). useTerminals takes both (env for shell ops, session for tab UX persistence). Slice actions key writes by env id directly, removing the silent no-op when the session→env mapping wasn't set yet. * refactor(web): wire env id through dockview, right-panel, and SSR call sites Dockview "+" menu, terminal scripts dropdown, dev-preview button, right-panel "+" button, Ctrl+\` toggle, watermark, and panel-close cleanup all source the env id from useEnvironmentId() / store and pass it to createUserShell/stopUserShell. Shell-mode passthrough terminal no longer blocks on session id — env id is sufficient. SSR resolves the active session's task_environment_id from the session list and calls fetchTerminals with the env id. * test(e2e): cover env-keyed terminal new-tab flows + WS payload guard New spec exercises the right-panel "+" button and the dockview "+ → Terminal" menu, asserts the new tab connects, and intercepts outgoing user_shell.create WS frames to verify the payload carries task_environment_id and never session_id. A heal-coverage assertion checks every listed session has task_environment_id populated post-boot. * fix(e2e): drop right-panel '+' test, dockview is the only '+' path on desktop The mobile TaskRightPanel SessionTabs aren't rendered at desktop viewports — the dockview '+' menu is the sole new-tab affordance, so testing the mobile path on chromium just timed out clicking a button that never mounted. Consolidate into a single dockview '+ → Terminal' test that asserts the new panel gets the backend-assigned 'Terminal 2' label (proving env-shared shell list) plus the WS-payload regression guard. * fix(e2e): assert dockview terminal-tab count, not 'Terminal 2' label Dockview hardcodes the panel title to 'Terminal' for every tab — only the mobile right-panel SessionTabs surface the backend's 'Terminal N' label. Switch the assertion to count tabs to keep the e2e green. * test(backend): cover SQL NULL branch of healSessionTaskEnvironmentIDs Adds insertSessionWithNullEnvID and TestHealSessionTaskEnvironmentIDs_LinksNullOrphans so the WHERE-clause IS NULL predicate is exercised — the existing tests only seeded empty-string FKs. Also adds LIMIT 1 to the heal subquery as a defensive measure: the unique index already guarantees ≤1 row per task at runtime, but the SQL now reads as deterministic in isolation. * refactor(web): drop dead taskGateOk on agent terminal WS gate Agent passthrough routes by sessionId, not taskId — taskId was only used for logging. Gating the WS effect on taskId forced agent terminals to wait for useSession() to hydrate, adding a refresh-race window that didn't guard any real precondition. connectionKey + canConnect already cover the meaningful preconditions. * fix(web): stamp env id on terminal panels for accurate cleanup addTerminalPanel now bakes environmentId into the panel's params at creation time. The dockview-layout-setup onDidRemovePanel handler prefers the stamped env over re-deriving from the active session, so closing a terminal that was opened under a different task no longer calls stopUserShell with the wrong env (which silently no-ops on the backend and leaks the shell process until session teardown). Falls back to the active session's env for legacy panels persisted before the param existed. * fix(web): keep task id logging out of terminal reconnect deps
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { useAppStoreApi } from "@/components/state-provider";
|
|
import { createUserShell } from "@/lib/api/domains/user-shell-api";
|
|
import { matchesShortcut } from "@/lib/keyboard/utils";
|
|
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
|
|
import type { DockviewApi } from "dockview-react";
|
|
|
|
function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) {
|
|
const activePanel = api.activePanel;
|
|
if (!activePanel) return;
|
|
|
|
const panels = activePanel.group.panels;
|
|
if (panels.length <= 1) return;
|
|
|
|
const currentIndex = panels.findIndex((p) => p.id === activePanel.id);
|
|
if (currentIndex === -1) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const direction = e.code === "BracketLeft" ? -1 : 1;
|
|
const nextIndex = (currentIndex + direction + panels.length) % panels.length;
|
|
panels[nextIndex].api.setActive();
|
|
}
|
|
|
|
function handleTerminalToggle(
|
|
e: KeyboardEvent,
|
|
api: DockviewApi,
|
|
previousPanelIdRef: React.MutableRefObject<string | null>,
|
|
getEnvironmentId: () => string | null,
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const activePanel = api.activePanel;
|
|
const isTerminalFocused = activePanel?.id.startsWith("terminal-") ?? false;
|
|
|
|
if (isTerminalFocused) {
|
|
const prevId = previousPanelIdRef.current;
|
|
const target = prevId ? api.getPanel(prevId) : api.getPanel("chat");
|
|
if (target) target.api.setActive();
|
|
previousPanelIdRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (activePanel) {
|
|
previousPanelIdRef.current = activePanel.id;
|
|
}
|
|
|
|
const terminalPanel = api.panels.find((p) => p.id.startsWith("terminal-"));
|
|
if (terminalPanel) {
|
|
terminalPanel.api.setActive();
|
|
return;
|
|
}
|
|
|
|
const environmentId = getEnvironmentId();
|
|
if (!environmentId) return;
|
|
|
|
createUserShell(environmentId)
|
|
.then((result) => {
|
|
useDockviewStore.getState().addTerminalPanel(result.terminalId, undefined, environmentId);
|
|
})
|
|
.catch((err) => {
|
|
console.warn("Failed to create terminal shell:", err);
|
|
});
|
|
}
|
|
|
|
/** Returns true if the active element is a text input or contenteditable. */
|
|
function isEditableTarget(e: KeyboardEvent): boolean {
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
return (
|
|
tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable === true
|
|
);
|
|
}
|
|
|
|
function handleLayoutToggle(
|
|
e: KeyboardEvent,
|
|
overrides: StoredShortcutOverrides | undefined,
|
|
): boolean {
|
|
if (isEditableTarget(e)) return false;
|
|
|
|
if (matchesShortcut(e, getShortcut("TOGGLE_SIDEBAR", overrides))) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
useDockviewStore.getState().toggleSidebar();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleBottomTerminal(
|
|
e: KeyboardEvent,
|
|
appStore: ReturnType<typeof useAppStoreApi>,
|
|
previousFocusRef: React.MutableRefObject<Element | null>,
|
|
overrides: StoredShortcutOverrides | undefined,
|
|
): boolean {
|
|
// Toggle bottom terminal panel (default: Cmd/Ctrl+J).
|
|
// Note: no isEditableTarget guard here. The default binding is not a standard text
|
|
// editing shortcut, and we must preventDefault even when an xterm textarea
|
|
// is focused — otherwise the un-prevented event causes escape-sequence
|
|
// artifacts (e.g. trailing "R" from cursor-position reports during resize).
|
|
if (matchesShortcut(e, getShortcut("BOTTOM_TERMINAL", overrides))) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const isOpen = appStore.getState().bottomTerminal.isOpen;
|
|
|
|
if (!isOpen) {
|
|
// Opening: save the currently focused element to restore later
|
|
previousFocusRef.current = document.activeElement;
|
|
}
|
|
|
|
appStore.getState().toggleBottomTerminal();
|
|
|
|
if (isOpen) {
|
|
// Closing: restore focus to the previously focused element
|
|
const prev = previousFocusRef.current;
|
|
if (prev instanceof HTMLElement && prev.isConnected) {
|
|
prev.focus({ preventScroll: true });
|
|
} else {
|
|
// Fallback: focus the chat panel
|
|
const api = useDockviewStore.getState().api;
|
|
const chatPanel = api?.getPanel("chat");
|
|
if (chatPanel) chatPanel.api.setActive();
|
|
}
|
|
previousFocusRef.current = null;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Global editor keybinds for dockview:
|
|
* - Cmd/Ctrl+Shift+[ / ] — navigate prev/next tab in active group
|
|
* - Ctrl+` — toggle terminal focus
|
|
* - Cmd/Ctrl+B — toggle sidebar
|
|
* - Cmd/Ctrl+J — toggle bottom terminal panel
|
|
*/
|
|
export function useEditorKeybinds() {
|
|
const previousPanelIdRef = useRef<string | null>(null);
|
|
const previousFocusRef = useRef<Element | null>(null);
|
|
const appStore = useAppStoreApi();
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
const api = useDockviewStore.getState().api;
|
|
if (!api) return;
|
|
|
|
const overrides = appStore.getState().userSettings.keyboardShortcuts;
|
|
|
|
const isTabNav =
|
|
(e.metaKey || e.ctrlKey) &&
|
|
e.shiftKey &&
|
|
(e.code === "BracketLeft" || e.code === "BracketRight");
|
|
|
|
if (isTabNav) {
|
|
handleTabNavigation(e, api);
|
|
return;
|
|
}
|
|
|
|
const isTerminalToggle = e.ctrlKey && !e.metaKey && !e.shiftKey && e.code === "Backquote";
|
|
|
|
if (isTerminalToggle) {
|
|
handleTerminalToggle(e, api, previousPanelIdRef, () => {
|
|
const state = appStore.getState();
|
|
const sid = state.tasks.activeSessionId;
|
|
return sid ? (state.environmentIdBySessionId[sid] ?? null) : null;
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (handleLayoutToggle(e, overrides)) return;
|
|
handleBottomTerminal(e, appStore, previousFocusRef, overrides);
|
|
};
|
|
|
|
// Use capture phase so we receive events before xterm.js
|
|
window.addEventListener("keydown", handler, true);
|
|
return () => window.removeEventListener("keydown", handler, true);
|
|
}, [appStore]);
|
|
}
|