Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ui/components/Workspace/aclPaneHost.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ onBeforeUnmount(() => {
:description="description"
class="mb-4"
/>
<p v-else class="mb-4 text-sm text-[var(--app-text-secondary)]">{{ description }}</p>
<p v-else class="mb-4 text-sm text-(--app-text-secondary)">{{ description }}</p>
<AclDialogContent :group="group" embedded :chrome="false" />
</template>
<template #footer>
Expand Down
13 changes: 13 additions & 0 deletions ui/components/Workspace/terminalAiCommandPopover.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { expect, it } from "vitest";
import popover from "./terminalAiCommandPopover.vue?raw";

it("limits quick approval to one alert and restores the composer after output", () => {
expect(popover).toContain("const quickApproval = computed(() => (visibleApprovals.value.length === 1");
expect(popover).toContain("isTerminalAiApprovalShortcut(event, isMacOS.value)");
expect(popover).toContain('void decideApproval(quickApproval.value.id, "approve")');
expect(popover).toContain("if (wasOpen && wasLocked) focusInput()");
expect(popover).toContain('const historyShortcutLabel = computed(() => (isMacOS.value ? "⌘ ⇧ K" : "Ctrl ⇧ K"));');
expect(popover).toContain('const approvalShortcutLabel = computed(() => (isMacOS.value ? "⌘ ↵" : "Ctrl ↵"));');
expect(popover).toContain('v-if="quickApproval?.id === approval.id"');
expect(popover).toContain("{{ approvalShortcutLabel }}");
});
50 changes: 39 additions & 11 deletions ui/components/Workspace/terminalAiCommandPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {
workspaceAssistantTerminalTargets
} from "~/composables/useWorkspaceAssistantSession";
import { resolveWorkspaceTerminalTarget } from "~/composables/useWorkspaceTerminalTasks";
import { isTerminalAiCommandShortcut, terminalAiLiveTurn } from "~/utils/terminalAiCommand";
import {
isTerminalAiApprovalShortcut,
isTerminalAiCommandShortcut,
terminalAiLiveTurn
} from "~/utils/terminalAiCommand";

const props = defineProps<{ pane: WorkspacePane }>();
const { t } = useI18n();
Expand All @@ -46,7 +50,8 @@ const draft = computed({
}
});
const shortcutLabel = computed(() => (isMacOS.value ? "⌘K" : "Ctrl K"));
const historyShortcutLabel = computed(() => (isMacOS.value ? "⌘⇧K" : "Ctrl⇧K"));
const historyShortcutLabel = computed(() => (isMacOS.value ? "⌘ ⇧ K" : "Ctrl ⇧ K"));
const approvalShortcutLabel = computed(() => (isMacOS.value ? "⌘ ↵" : "Ctrl ↵"));
const shortcutHint = computed(() => t("TerminalAi.ShortcutHint", { shortcut: shortcutLabel.value }));
const sendLabel = computed(() => (submitting.value ? t("TerminalAi.Sending") : t("TerminalAi.Send")));
const assistantBusy = computed(() => Boolean(scopeId.value && isWorkspaceAssistantBusy(scopeId.value)));
Expand All @@ -59,6 +64,7 @@ const livePrompt = computed(() => liveTurn.value.lastUser || submittedPrompt.val
const visibleApprovals = computed(() =>
liveTurn.value.pendingApprovals.filter((item) => !decidedApprovals.has(item.id))
);
const quickApproval = computed(() => (visibleApprovals.value.length === 1 ? visibleApprovals.value[0] : null));
const activeTerminalTasks = computed(() => assistantSession.value?.terminalTasks.filter((task) => task.active) || []);
const live = computed(
() =>
Expand Down Expand Up @@ -174,6 +180,18 @@ function handleWindowKeydown(event: KeyboardEvent) {
close();
return;
}
if (
open.value &&
quickApproval.value &&
!approving.value &&
!tour.tourActive.value &&
isTerminalAiApprovalShortcut(event, isMacOS.value)
) {
event.preventDefault();
event.stopPropagation();
void decideApproval(quickApproval.value.id, "approve");
return;
}
if (tour.tourActive.value || !isTerminalAiCommandShortcut(event, isMacOS.value) || !available.value) return;
const target = event.target instanceof Element ? event.target : null;
const xterm = getKokoTerminalElement(props.pane.id);
Expand Down Expand Up @@ -325,9 +343,13 @@ watch(
);
watch(
() => [open.value, composerLocked.value] as const,
([isOpen, locked]) => {
if (isOpen && !locked) startPlaceholderType();
else stopPlaceholderType();
([isOpen, locked], [wasOpen, wasLocked]) => {
if (isOpen && !locked) {
startPlaceholderType();
if (wasOpen && wasLocked) focusInput();
} else {
stopPlaceholderType();
}
}
);
watch(open, async (isOpen) => {
Expand Down Expand Up @@ -411,7 +433,7 @@ onBeforeUnmount(() => {
tabindex="0"
:title="t('RightPanel.AIMovePanel')"
:aria-label="t('RightPanel.AIMovePanel')"
class="terminal-ai-head grid shrink-0 touch-none select-none grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2.5 pt-2 outline-none focus-visible:ring-2 focus-visible:ring-(--app-focus-ring)"
class="terminal-ai-head grid shrink-0 touch-none select-none grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 outline-none focus-visible:ring-2 focus-visible:ring-(--app-focus-ring)"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
>
<div class="flex min-w-0 items-center gap-1.5 text-[11px] tracking-[0.02em] text-muted">
Expand Down Expand Up @@ -460,7 +482,7 @@ onBeforeUnmount(() => {
class="terminal-ai-live space-y-2 px-2.5 pb-2.5 pt-2"
@scroll.passive="onLiveScroll"
>
<p v-if="livePrompt" class="rounded-lg bg-[var(--app-selected-soft)] px-2.5 py-1.5 text-xs leading-5">
<p v-if="livePrompt" class="rounded-lg bg-(--app-selected-soft) px-2.5 py-1.5 text-xs leading-5">
{{ livePrompt }}
</p>
<div
Expand Down Expand Up @@ -498,9 +520,13 @@ onBeforeUnmount(() => {
size="xs"
:color="approvalColor"
:loading="approving"
:label="t('RightPanel.AIApprove')"
@click="decideApproval(approval.id, 'approve')"
/>
>
{{ t("RightPanel.AIApprove") }}
<span v-if="quickApproval?.id === approval.id" class="text-[11px] font-normal opacity-80">
{{ approvalShortcutLabel }}
</span>
</UButton>
</div>
</div>
</template>
Expand Down Expand Up @@ -558,8 +584,10 @@ onBeforeUnmount(() => {
:disabled="!draft.trim()"
@click="submit"
>
{{ sendLabel }}
<span class="text-[10px] font-normal opacity-80">↵</span>
<span class="mt-0.5">
{{ sendLabel }}
</span>
<span class="text-[10px] font-normal mt-0.75 opacity-80">↵</span>
</UButton>
</div>

Expand Down
47 changes: 31 additions & 16 deletions ui/composables/useAiPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
let tabSequence = 0;
let tabA = "";
let tabB = "";

vi.mock("@vueuse/core", async (importOriginal) => {
const original = await importOriginal<typeof import("@vueuse/core")>();
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});

Expand Down
20 changes: 13 additions & 7 deletions ui/composables/useAiPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ const usePanelPreferences = createGlobalState(() => {
writeDefaults: false
});
const narrow = useMediaQuery("(max-width: 767px)");
const narrowOpen = shallowRef(false);
const openByTabId = shallowReactive(new Map<string, boolean>());
const narrowOpenByTabId = shallowReactive(new Map<string, boolean>());
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;
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 0 additions & 1 deletion ui/koko/composables/terminal/useTerminalInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion ui/koko/tests/terminal/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof vi.fn> } | null;
Expand Down
8 changes: 4 additions & 4 deletions ui/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -136,7 +136,7 @@ const handleAiHistoryShortcut = (event: KeyboardEvent) => {
if (!isTerminalAiHistoryShortcut(event, isMacOS.value)) return;
event.preventDefault();
event.stopPropagation();
openAi();
toggleAi();
};

const handleChromeShortcut = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -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"
>
<span
Expand Down Expand Up @@ -434,7 +434,7 @@ onBeforeUnmount(() => {
aria-modal="true"
:aria-label="$t('Common.Settings')"
data-state="open"
class="fixed inset-0 z-[200]"
class="fixed inset-0 z-200"
>
<SettingsShell mode="inline" :active-section="activeSettingsSection" class="h-full">
<Transition name="settings-section" mode="out-in">
Expand Down
Loading
Loading