diff --git a/ui/components/Workspace/aclPaneHost.vue b/ui/components/Workspace/aclPaneHost.vue index 2d11ccd59..21971fd9a 100644 --- a/ui/components/Workspace/aclPaneHost.vue +++ b/ui/components/Workspace/aclPaneHost.vue @@ -72,7 +72,7 @@ onBeforeUnmount(() => { :description="description" class="mb-4" /> -

{{ description }}

+

{{ description }}

@@ -558,8 +584,10 @@ onBeforeUnmount(() => { :disabled="!draft.trim()" @click="submit" > - {{ sendLabel }} - ↵ + + {{ sendLabel }} + + ↵ diff --git a/ui/composables/useAiPanel.test.ts b/ui/composables/useAiPanel.test.ts index 4918aea18..e78c3232b 100644 --- a/ui/composables/useAiPanel.test.ts +++ b/ui/composables/useAiPanel.test.ts @@ -11,6 +11,9 @@ const activeTab = computed(() => tabs.value.find((tab) => tab.id === activeTabId const workspaceTabs = { tabs, activeTabId, activeTab }; const narrow = shallowRef(false); const saved = new Map(); +let tabSequence = 0; +let tabA = ""; +let tabB = ""; vi.mock("@vueuse/core", async (importOriginal) => { const original = await importOriginal(); @@ -36,8 +39,10 @@ describe("AI overlay panel", () => { setWorkspaceAiEnabled(true); await nextTick(); vi.stubGlobal("useWorkspaceTabs", () => workspaceTabs); - tabs.value = [{ id: "tab-a" }, { id: "tab-b" }]; - activeTabId.value = "tab-a"; + tabA = `tab-a-${++tabSequence}`; + tabB = `tab-b-${tabSequence}`; + tabs.value = [{ id: tabA }, { id: tabB }]; + activeTabId.value = tabA; const panel = useAiPanel(); panel.setOpen(false); panel.setPanelWidth(380); @@ -49,32 +54,42 @@ describe("AI overlay panel", () => { afterAll(() => vi.unstubAllGlobals()); - it("defaults to open on desktop and remembers visibility across tabs and consumers", async () => { + it("remembers visibility independently for each workspace tab", () => { const panel = useAiPanel(); - expect(useLocalStorage).toHaveBeenCalledWith("jumpserver-client:ai-panel-open", true, { writeDefaults: false }); + panel.setOpen(false); + expect(panel.open.value).toBe(false); + + activeTabId.value = tabB; panel.openAi(); expect(panel.open.value).toBe(true); - activeTabId.value = "tab-b"; + activeTabId.value = tabA; + expect(panel.open.value).toBe(false); + panel.toggleAi(); expect(panel.open.value).toBe(true); - useAiPanel().setOpen(false); - await nextTick(); - activeTabId.value = "tab-a"; + panel.toggleAi(); expect(panel.open.value).toBe(false); - expect(saved.get("jumpserver-client:ai-panel-open")).toBe("false"); - expect(useLocalStorage("jumpserver-client:ai-panel-open", true).value).toBe(false); + + activeTabId.value = tabB; + expect(useAiPanel().open.value).toBe(true); }); - it("starts narrow screens closed and preserves the desktop preference", async () => { + it("starts narrow screens closed and keeps their tab state separate from desktop", async () => { const panel = useAiPanel(); panel.openAi(); narrow.value = true; await nextTick(); expect(panel.open.value).toBe(false); panel.openAi(); - expect(useAiPanel().open.value).toBe(true); - panel.setOpen(false); - expect(saved.get("jumpserver-client:ai-panel-open")).toBe("true"); + expect(panel.open.value).toBe(true); + + activeTabId.value = tabB; + expect(panel.open.value).toBe(false); + panel.openAi(); + expect(panel.open.value).toBe(true); + + activeTabId.value = tabA; + expect(panel.open.value).toBe(true); narrow.value = false; await nextTick(); expect(panel.open.value).toBe(true); @@ -120,7 +135,7 @@ describe("AI overlay panel", () => { it("blocks opening and clears terminal prompts when AI is disabled without losing preferences", () => { const panel = useAiPanel(); panel.openAi(); - activeTabId.value = "tab-b"; + activeTabId.value = tabB; panel.openAi(); const binding = { loginContext: "login", resourceId: "resource", agentId: "agent" }; panel.requestTerminalPrompt("pane", "Inspect", binding); @@ -133,7 +148,7 @@ describe("AI overlay panel", () => { expect(panel.pendingTerminalPrompt.value).toBeNull(); setWorkspaceAiEnabled(true); expect(panel.open.value).toBe(true); - activeTabId.value = "tab-a"; + activeTabId.value = tabA; expect(panel.open.value).toBe(true); }); diff --git a/ui/composables/useAiPanel.ts b/ui/composables/useAiPanel.ts index 067cce903..13e2907c3 100644 --- a/ui/composables/useAiPanel.ts +++ b/ui/composables/useAiPanel.ts @@ -21,15 +21,16 @@ const usePanelPreferences = createGlobalState(() => { writeDefaults: false }); const narrow = useMediaQuery("(max-width: 767px)"); - const narrowOpen = shallowRef(false); + const openByTabId = shallowReactive(new Map()); + const narrowOpenByTabId = shallowReactive(new Map()); watch( [narrow, workspaceAiEnabled], () => { - narrowOpen.value = false; + narrowOpenByTabId.clear(); }, { flush: "sync" } ); - return { preferredOpen, storedWidth, narrow, narrowOpen }; + return { preferredOpen, storedWidth, narrow, openByTabId, narrowOpenByTabId }; }); interface TerminalPromptBinding { loginContext: string; @@ -63,14 +64,19 @@ export function resolveUnifiedAiPanel(context: UnifiedAiPanelContext): UnifiedAi } export const useAiPanel = () => { - const { preferredOpen, storedWidth, narrow, narrowOpen } = usePanelPreferences(); - const open = computed(() => workspaceAiEnabled.value && (narrow.value ? narrowOpen.value : preferredOpen.value)); + const { activeTabId } = useWorkspaceTabs(); + const { preferredOpen, storedWidth, narrow, openByTabId, narrowOpenByTabId } = usePanelPreferences(); + const tabOpenState = computed(() => (narrow.value ? narrowOpenByTabId : openByTabId)); + const defaultOpen = computed(() => (narrow.value ? false : preferredOpen.value)); + const open = computed( + () => workspaceAiEnabled.value && (tabOpenState.value.get(activeTabId.value) ?? defaultOpen.value) + ); const panelWidth = computed(() => normalizePanelWidth(storedWidth.value)); const setOpen = (value: boolean) => { if (value && !workspaceAiEnabled.value) return; - if (narrow.value) narrowOpen.value = value; - else preferredOpen.value = value; + if (activeTabId.value) tabOpenState.value.set(activeTabId.value, value); + else if (!narrow.value) preferredOpen.value = value; }; const setPanelWidth = (width: number) => { diff --git a/ui/koko/composables/terminal/useTerminalInput.ts b/ui/koko/composables/terminal/useTerminalInput.ts index 1f920c44a..3cc5b7950 100644 --- a/ui/koko/composables/terminal/useTerminalInput.ts +++ b/ui/koko/composables/terminal/useTerminalInput.ts @@ -102,7 +102,6 @@ export function useKokoTerminalInput(options: { event.stopImmediatePropagation(); }; const onMouseLeave = () => { - terminal.blur(); options.sendHostEvent(HOST_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE, { content: getXTerminalLineContent(10, terminal), sessionId: options.sessionId.value, diff --git a/ui/koko/tests/terminal/terminal.test.ts b/ui/koko/tests/terminal/terminal.test.ts index 6009b180a..ec504b5e2 100644 --- a/ui/koko/tests/terminal/terminal.test.ts +++ b/ui/koko/tests/terminal/terminal.test.ts @@ -1,4 +1,4 @@ -import { MESSAGE_TYPE } from "@jumpserver/connectors-core"; +import { HOST_MESSAGE_TYPE, MESSAGE_TYPE } from "@jumpserver/connectors-core"; import { readText, writeText } from "clipboard-polyfill"; import { afterEach, expect, it, vi } from "vitest"; import { computed, ref, shallowRef } from "vue"; @@ -149,6 +149,55 @@ it("blocks denied copy and paste events before xterm handles them", () => { input.stop(); }); +it("keeps xterm focused while reporting content after mouse leave", () => { + const container = new EventTarget(); + const sendHostEvent = vi.fn(); + const terminal = { + attachCustomKeyEventHandler: vi.fn(), + blur: vi.fn(), + focus: vi.fn(), + getSelection: vi.fn(() => ""), + hasSelection: vi.fn(() => false), + onData: vi.fn(), + onResize: vi.fn(), + onSelectionChange: vi.fn(), + buffer: { active: { length: 0, getLine: vi.fn() } } + }; + const input = useKokoTerminalInput({ + container: shallowRef(container as HTMLElement), + terminal: ref(terminal as never), + socket: ref(null), + terminalId: ref("terminal-1"), + sessionId: ref("session-1"), + selectionText: ref(""), + lastSendTime: ref(new Date()), + fit: vi.fn(), + isSocketOpen: vi.fn(() => true), + isZmodemActive: vi.fn(() => false), + abortZmodem: vi.fn(), + onContextMenu: vi.fn(), + getTerminalConfig: vi.fn(() => ({})), + onResize: vi.fn(), + onHostKey: vi.fn(), + inputLocked: vi.fn(() => false), + sendHostEvent, + sendToHost: vi.fn(), + sendMittEvent: vi.fn(), + validateClipboardText: vi.fn(() => true) + }); + input.start(); + container.dispatchEvent(new Event("mouseleave")); + + expect(terminal.blur).not.toHaveBeenCalled(); + expect(sendHostEvent).toHaveBeenCalledWith(HOST_MESSAGE_TYPE.TERMINAL_CONTENT_RESPONSE, { + content: "", + sessionId: "session-1", + terminalId: "terminal-1" + }); + + input.stop(); +}); + function startContextMenuInput(overrides: { getTerminalConfig: () => { quickPaste?: string; ctrlCAsCtrlZ?: string }; socket?: { send: ReturnType } | null; diff --git a/ui/layouts/default.vue b/ui/layouts/default.vue index 8b0b3ece7..6376ec614 100644 --- a/ui/layouts/default.vue +++ b/ui/layouts/default.vue @@ -34,7 +34,7 @@ const { statusBarVisible } = useSettingManager(); const { open: rightPanelOpen, toggle: toggleRightPanel } = useRightPanel(); -const { open: aiPanelOpen, setOpen: setAiPanelOpen, openAi } = useAiPanel(); +const { open: aiPanelOpen, setOpen: setAiPanelOpen, toggleAi } = useAiPanel(); const localePath = useLocalePath(); const { open: settingsOpen, activeSection: activeSettingsSection, openSettings, closeSettings } = useSettingsWindow(); const { recentConnections } = useRecentConnections(); @@ -136,7 +136,7 @@ const handleAiHistoryShortcut = (event: KeyboardEvent) => { if (!isTerminalAiHistoryShortcut(event, isMacOS.value)) return; event.preventDefault(); event.stopPropagation(); - openAi(); + toggleAi(); }; const handleChromeShortcut = (event: KeyboardEvent) => { @@ -384,7 +384,7 @@ onBeforeUnmount(() => { type="button" :aria-label="$t('TabMenu.ExitFocusMode')" :title="$t('TabMenu.ExitFocusModeHint')" - class="group absolute right-3 top-1/2 z-50 flex h-12 w-1.5 -translate-y-1/2 items-center justify-end overflow-hidden rounded-l-lg border border-r-0 border-(--app-border) bg-[var(--app-surface-panel)] text-[var(--app-muted)] opacity-45 shadow-sm transition-[width,opacity] hover:w-32 hover:opacity-100 focus-visible:w-32 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" + class="group absolute right-3 top-1/2 z-50 flex h-12 w-1.5 -translate-y-1/2 items-center justify-end overflow-hidden rounded-l-lg border border-r-0 border-(--app-border) bg-(--app-surface-panel) text-(--app-muted) opacity-45 shadow-sm transition-[width,opacity] hover:w-32 hover:opacity-100 focus-visible:w-32 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" @click.stop="exitFocusMode" > { aria-modal="true" :aria-label="$t('Common.Settings')" data-state="open" - class="fixed inset-0 z-[200]" + class="fixed inset-0 z-200" > diff --git a/ui/utils/terminalAiCommand.test.ts b/ui/utils/terminalAiCommand.test.ts index 3412fdc82..6a8290495 100644 --- a/ui/utils/terminalAiCommand.test.ts +++ b/ui/utils/terminalAiCommand.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + isTerminalAiApprovalShortcut, isTerminalAiCommandShortcut, isTerminalAiHistoryShortcut, shouldShowTerminalAiCaretHint, @@ -35,6 +36,18 @@ describe("Terminal AI command shortcut", () => { ); }); + it("approves one lightweight alert with Command or Control plus Enter", () => { + expect(isTerminalAiApprovalShortcut(shortcutEvent({ code: "Enter", metaKey: true }), true)).toBe(true); + expect(isTerminalAiApprovalShortcut(shortcutEvent({ code: "Enter", ctrlKey: true }), false)).toBe(true); + expect(isTerminalAiApprovalShortcut(shortcutEvent({ code: "Enter", metaKey: true }), false)).toBe(false); + expect(isTerminalAiApprovalShortcut(shortcutEvent({ code: "Enter", metaKey: true, shiftKey: true }), true)).toBe( + false + ); + expect(isTerminalAiApprovalShortcut(shortcutEvent({ code: "Enter", metaKey: true, repeat: true }), true)).toBe( + false + ); + }); + it("reopens the HUD for an active task and the composer otherwise", () => { expect(terminalAiCommandShortcutAction(true, false)).toBe("popover"); expect(terminalAiCommandShortcutAction(true, true)).toBe("hud"); diff --git a/ui/utils/terminalAiCommand.ts b/ui/utils/terminalAiCommand.ts index df3573d7b..824199c21 100644 --- a/ui/utils/terminalAiCommand.ts +++ b/ui/utils/terminalAiCommand.ts @@ -17,6 +17,11 @@ export function isTerminalAiHistoryShortcut(event: TerminalAiShortcutEvent, isMa return !event.repeat && !event.altKey && event.shiftKey && primaryModifier && event.code === "KeyK"; } +export function isTerminalAiApprovalShortcut(event: TerminalAiShortcutEvent, isMacOS: boolean) { + const primaryModifier = isMacOS ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey; + return !event.repeat && !event.altKey && !event.shiftKey && primaryModifier && event.code === "Enter"; +} + export function terminalAiCommandShortcutAction(available: boolean, busy: boolean) { if (!available) return "ignore" as const; return busy ? ("hud" as const) : ("popover" as const);