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
11 changes: 11 additions & 0 deletions .changeset/split-terminal-and-agents-panels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@inkeep/open-knowledge": minor
---

The terminal and agent conversations are now two separate panels instead of one shared dock. The terminal owns the bottom of the editor and still opens with ⌘J; agent conversations own the right panel and open with ⌘L. Both can be open at once, each keeps its own size, tabs, and reload state, and closing one leaves the other alone — so you no longer have to give up your terminal to see an agent. The dock-position toggle is gone with the split; a terminal that used to sit in the right panel moves to the bottom on first launch, and the right panel keeps the width you had set.

A new tab in the terminal panel opens a plain shell by default — you no longer get dropped into a CLI you never chose. The New button's dropdown still lists every terminal CLI, though, so you can start a tab directly in one; that choice sticks, and the + button, ⌘J, and ⇧⌘J repeat it until you pick Terminal again. The agents panel's New button remains dedicated to in-app agents. Sending a passage to AI — from the selection bubble, a code block's Ask action, or ⌘J with selected text — still uses your preferred AI and reveals the panel where it runs.

The agents panel now keeps a small tab on the right edge whenever it is closed, so a conversation is always one click away even if you have never opened the panel. The terminal no longer has an edge tab of its own — ⌘J and the View menu open it — which clears the bottom-right corner it used to share with the Ask AI composer.

The bottom Ask AI composer moves from ⌘L to ⇧⌘L (Ctrl+Shift+L on Windows and Linux), and now hides whenever either panel is open — both are already places to type a request, so it stepped aside rather than offering a third.
71 changes: 32 additions & 39 deletions packages/app/src/components/BottomComposer.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* The rich `@`-mention input (`ComposerMentionInput`) is mocked with a plain
* textarea double that mirrors its imperative handle + `onEmptyChange`/`onSubmit`
* contract, so these tests stay focused on the shell's responsibilities: the ⌘L
* contract, so these tests stay focused on the shell's responsibilities: the ⌘L
* focus shortcut, the rotating/reduced-motion placeholder, agent-picker +
* sticky-default wiring, Claude CLI terminal routing, the pending + clear flow,
* and the defensive null-input toast. The real input's mention behavior +
Expand Down Expand Up @@ -357,16 +357,32 @@ function getInput() {
return screen.getByRole('textbox', { name: 'Ask AI' }) as HTMLTextAreaElement;
}

function dispatchOpenAskAiShortcut() {
function makeOpenAskAiEvent() {
// ⇧⌘L on mac / Ctrl+Shift+L elsewhere. `code` (not `key`) is what the registry
// matches: shift makes the layout emit 'L', so keying off `key` would be
// case-sensitive for no reason.
const meta = new KeyboardEvent('keydown', {
key: 'l',
key: 'L',
code: 'KeyL',
metaKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
const event = matchesKeyboardShortcut(meta, 'open-ask-ai')
return matchesKeyboardShortcut(meta, 'open-ask-ai')
? meta
: new KeyboardEvent('keydown', { key: 'l', ctrlKey: true, bubbles: true, cancelable: true });
: new KeyboardEvent('keydown', {
key: 'L',
code: 'KeyL',
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
});
}

function dispatchOpenAskAiShortcut() {
const event = makeOpenAskAiEvent();
act(() => {
window.dispatchEvent(event);
});
Expand Down Expand Up @@ -451,7 +467,7 @@ describe('BottomComposer (shell behavior)', () => {
}
});

test('the ⌘L shortcut focuses the persistent field', async () => {
test('the ⌘L shortcut focuses the persistent field', async () => {
await renderComposer();
const input = getInput();
expect(document.activeElement).not.toBe(input);
Expand All @@ -478,7 +494,7 @@ describe('BottomComposer (shell behavior)', () => {
expect(document.activeElement).not.toBe(getInput());
});

test('⌘L is ignored while a native form field is focused (no caret theft)', async () => {
test('⌘L is ignored while a native form field is focused (no caret theft)', async () => {
await renderComposer();
const composerInput = getInput();
// A real native form field elsewhere in the page (e.g. a rename / search box).
Expand All @@ -488,22 +504,9 @@ describe('BottomComposer (shell behavior)', () => {
act(() => nativeField.focus());
expect(document.activeElement).toBe(nativeField);

// Dispatch ⌘L FROM the native field — the window capture handler sees it
// Dispatch ⌘L FROM the native field — the window capture handler sees it
// with `event.target` = the native input and must bail before preventDefault.
const meta = new KeyboardEvent('keydown', {
key: 'l',
metaKey: true,
bubbles: true,
cancelable: true,
});
const event = matchesKeyboardShortcut(meta, 'open-ask-ai')
? meta
: new KeyboardEvent('keydown', {
key: 'l',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
const event = makeOpenAskAiEvent();
act(() => {
nativeField.dispatchEvent(event);
});
Expand Down Expand Up @@ -1288,7 +1291,7 @@ describe('BottomComposer (dismiss / reopen)', () => {
expect(screen.queryByRole('textbox', { name: 'Ask AI' })).toBeNull();
});

test('⌘L while dismissed reopens (calls onReopen) instead of focusing', async () => {
test('⌘L while dismissed reopens (calls onReopen) instead of focusing', async () => {
const onReopen = vi.fn(() => {});
await renderComposer('notes', { dismissed: true, onReopen });

Expand Down Expand Up @@ -1378,25 +1381,15 @@ describe('BottomComposer (failure + defensive guards)', () => {
});

/**
* ⌘L is registered capture-phase on `window`, so it also outruns anything an
* ⌘L is registered capture-phase on `window`, so it also outruns anything an
* overlay installs. `defaultPrevented` is the load-bearing signal here: the
* handler cancels the keystroke before emitting, so an uncancelled ⌘L proves
* handler cancels the keystroke before emitting, so an uncancelled ⌘L proves
* the handler declined rather than proving the focus trap bounced it back.
*/
describe('BottomComposer ⌘L — overlay gate', () => {
function askAiEvent() {
const meta = new KeyboardEvent('keydown', {
key: 'l',
metaKey: true,
bubbles: true,
cancelable: true,
});
return matchesKeyboardShortcut(meta, 'open-ask-ai')
? meta
: new KeyboardEvent('keydown', { key: 'l', ctrlKey: true, bubbles: true, cancelable: true });
}
describe('BottomComposer ⇧⌘L — overlay gate', () => {
const askAiEvent = makeOpenAskAiEvent;

test('claims ⌘L with no overlay open', async () => {
test('claims ⌘L with no overlay open', async () => {
await renderComposer();

const event = askAiEvent();
Expand All @@ -1407,7 +1400,7 @@ describe('BottomComposer ⌘L — overlay gate', () => {
expect(event.defaultPrevented).toBe(true);
});

test('declines ⌘L while an overlay owns the keyboard', async () => {
test('declines ⌘L while an overlay owns the keyboard', async () => {
enableInstalledDesktopTargets();
const { BottomComposer } = await import('./BottomComposer');
const { Dialog, DialogContent, DialogDescription, DialogTitle } = await import(
Expand Down
22 changes: 11 additions & 11 deletions packages/app/src/components/BottomComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* carries a segmented "Ask <agent>" send control (the shared
* `AgentSplitButton`: primary submit + joined agent-picker chevron), a
* rotating-suggestion placeholder that cross-fades between prompts while
* empty, and the ⌘L focus shortcut.
* empty, and the ⌘L focus shortcut.
*
* Submitting dispatches the typed instruction to the resolved default agent
* (first installed, or the user's sticky pick) scoped to the current doc or
Expand Down Expand Up @@ -122,10 +122,10 @@ function markdownRelativePathStem(path: string): string | null {
}

/**
* Whether a keydown originated inside a native form field. ⌘L should still fire
* Whether a keydown originated inside a native form field. ⌘L should still fire
* from the ProseMirror body (a contentEditable root), so this is deliberately
* NARROWER than `isEditableShortcutTarget` — only native INPUT/TEXTAREA/SELECT
* are excluded, so ⌘L never steals a caret out of a real form field (e.g. the
* are excluded, so ⌘L never steals a caret out of a real form field (e.g. the
* rename input, a search box).
*/
function isNativeTextControl(target: EventTarget | null): boolean {
Expand Down Expand Up @@ -221,7 +221,7 @@ export function BottomComposer({
* touched-file lifecycle, scroll-inset/caret machinery) are skipped. */
folderPath?: string;
/** When dismissed, the field collapses to nothing (the host shows a reopen
* badge in the footer); the component stays mounted so ⌘L can reopen it. Doc
* badge in the footer); the component stays mounted so ⌘L can reopen it. Doc
* mode only — folder mode is always visible (no footer to dock a badge in). */
dismissed?: boolean;
onDismiss?: () => void;
Expand Down Expand Up @@ -385,7 +385,7 @@ export function BottomComposer({
};
}, [dismissed, surface, docName, folderMode]);

// Mirror the latest dismissed/onReopen into refs so the once-bound ⌘L handler
// Mirror the latest dismissed/onReopen into refs so the once-bound ⌘L handler
// reads current values without re-subscribing (refs written in an effect, not
// during render, keeps React Compiler happy).
const dismissedRef = useRef(dismissed);
Expand All @@ -395,7 +395,7 @@ export function BottomComposer({
onReopenRef.current = onReopen;
});

// Single open+focus path, shared by ⌘L and the editor's "Ask AI" selection
// Single open+focus path, shared by ⌘L and the editor's "Ask AI" selection
// affordance (the bubble-menu button dispatches the same event): if the field
// is dismissed it reopens first (the reopen effect below then focuses on the
// dismissed -> visible flip); otherwise it focuses the input directly.
Expand All @@ -407,13 +407,13 @@ export function BottomComposer({
return subscribeToOpenAskAiComposer(openAndFocus);
}, []);

// ⌘L routes through the shared event so the button and the shortcut never
// ⌘L routes through the shared event so the button and the shortcut never
// duplicate the reopen/focus logic.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!matchesKeyboardShortcut(event, 'open-ask-ai')) return;
if (isOverlayLayerOpen()) return;
// Don't hijack ⌘L when the caret is in a native form field (rename input,
// Don't hijack ⌘L when the caret is in a native form field (rename input,
// search box, …) — only swallow it for the editor body / global context.
if (isNativeTextControl(event.target)) return;
event.preventDefault();
Expand All @@ -424,7 +424,7 @@ export function BottomComposer({
}, []);

// Focus the input only on a genuine reopen (dismissed true -> false), so a
// badge click or ⌘L lands the caret in the field. Comparing against the
// badge click or ⌘L lands the caret in the field. Comparing against the
// PREVIOUS dismissed value (rather than a "skip the first render" ref) keeps
// the mount itself from focusing — and, crucially, survives React StrictMode's
// dev-only effect double-invoke, which defeats a skip-first-render ref (the
Expand Down Expand Up @@ -933,7 +933,7 @@ export function BottomComposer({
};

// Dismissed: render nothing (the host shows the footer reopen badge). The
// component stays mounted above this point so the ⌘L handler can reopen it.
// component stays mounted above this point so the ⌘L handler can reopen it.
if (dismissed) return null;

// Compact, Cursor-style selection chip: `name (range)` — the doc basename
Expand Down Expand Up @@ -962,7 +962,7 @@ export function BottomComposer({
// the card stacks its children vertically. The card markup is mode-agnostic;
// only the outer host wrapper (overlay vs in-flow) differs below.
const card = (
// biome-ignore lint/a11y/noStaticElementInteractions: pointer clicks only delegate focus to the composer's editable; keyboard users focus it directly (Tab / ⌘L).
// biome-ignore lint/a11y/noStaticElementInteractions: pointer clicks only delegate focus to the composer's editable; keyboard users focus it directly (Tab / ⌘L).
<div
ref={cardRef}
// Click anywhere in the card's whitespace (padding, row gaps, the space
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/components/CommandPalette.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,11 @@ describe('Cmd+K menu-parity backfill', () => {
{ testid: 'command-palette-toggle-sidebar', query: 'sidebar', id: 'toggle-sidebar' },
{ testid: 'command-palette-toggle-doc-panel', query: 'document panel', id: 'toggle-doc-panel' },
{ testid: 'command-palette-toggle-terminal', query: 'hide terminal', id: 'toggle-terminal' },
{
testid: 'command-palette-toggle-agent-panel',
query: 'agents',
id: 'toggle-agent-panel',
},
{
testid: 'command-palette-toggle-show-hidden-files',
query: 'hidden files',
Expand Down
Loading