Skip to content
Open
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
5 changes: 3 additions & 2 deletions .claude/skills/freshell-orchestration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Point commands at the running Freshell server:

```bash
export FRESHELL_URL="http://localhost:3001"
export FRESHELL_TOKEN="<auth-token>"
export FRESHELL_TOKEN="$(grep AUTH_TOKEN /home/user/code/freshell/.env | cut -d= -f2)"
$FSH health
```

Expand All @@ -28,7 +28,8 @@ Use absolute paths for `--cwd` and `--editor`.
- Freshell CLI is an HTTP client over `/api/*`, not a local tmux socket client.
- Tabs and pane trees live in `layoutStore`.
- Terminal lifecycle + scrollback live in `terminalRegistry`.
- Pane kinds: `terminal`, `editor`, `browser`.
- Pane kinds: `terminal`, `editor`, `browser`, `agent-chat` (Claude/Codex), `picker` (transient).
- **Picker panes are ephemeral.** A freshly-created tab without `--mode`/`--browser`/`--editor` starts as a `picker` pane while the user chooses what to launch. Once they select, the picker is replaced by the real pane with a **new pane ID**. Never target a `picker` pane for splits or other mutations — wait until it resolves to its final kind, or use `--mode`/`--browser`/`--editor` flags on `new-tab`/`split-pane` to skip the picker entirely.
- Typical loop: `new-tab/split-pane` -> `send-keys` -> `wait-for` -> `capture-pane`/`screenshot-*`.

## Command reference
Expand Down
299 changes: 299 additions & 0 deletions docs/plans/2026-02-28-fix-escape-interrupt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
# Fix Escape Key Not Interrupting Freshclaude Agent Chat

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make the Escape key reliably interrupt a running Freshclaude agent chat session, regardless of which element inside the agent-chat pane has focus.

**Architecture:** Move the Escape-to-interrupt handler from the `<textarea>` `onKeyDown` in `ChatComposer` up to a container-level `onKeyDown` on the `AgentChatView` wrapper `<div>`. Add `tabIndex={-1}` so the container can receive keyboard events. This scopes the interrupt to the agent-chat pane without affecting other panes (terminals, editors, browsers). The existing `handleContainerPointerUp` already restores focus inside the container on click.

**Tech Stack:** React 18, Vitest, Testing Library, userEvent

**Known issue:** All client-side tests currently fail with `act(...) is not supported in production builds of React` — this is a pre-existing environment issue on main, not caused by our changes. Write tests correctly; verify they are structurally sound even if the runner rejects them.

---

### Task 1: Add container-level Escape handler to AgentChatView

**Files:**
- Modify: `src/components/agent-chat/AgentChatView.tsx:377-378`

**Step 1: Write the failing test**

Create a new test file that verifies the container-level Escape behavior. This tests AgentChatView in isolation with a mocked Redux store and WebSocket client.

Create: `test/unit/client/components/agent-chat/AgentChatView-interrupt.test.tsx`

```tsx
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { Provider } from 'react-redux'
import { configureStore } from '@reduxjs/toolkit'
import AgentChatView from '../../../../../src/components/agent-chat/AgentChatView'
import agentChatReducer from '../../../../../src/store/agentChatSlice'
import panesReducer from '../../../../../src/store/panesSlice'
import settingsReducer from '../../../../../src/store/settingsSlice'
import type { AgentChatPaneContent } from '../../../../../src/store/paneTypes'

// Mock ws-client to capture sent messages
const mockSend = vi.fn()
vi.mock('../../../../../src/lib/ws-client', () => ({
getWsClient: () => ({
send: mockSend,
onReconnect: () => () => {},
}),
}))

// Mock api
vi.mock('../../../../../src/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn() },
}))

function makeStore(sessionState?: Record<string, unknown>) {
return configureStore({
reducer: {
agentChat: agentChatReducer,
panes: panesReducer,
settings: settingsReducer,
},
preloadedState: sessionState,
})
}

const basePaneContent: AgentChatPaneContent = {
kind: 'agent-chat',
provider: 'freshclaude',
createRequestId: 'req-1',
sessionId: 'sess-1',
status: 'running',
}

describe('AgentChatView Escape interrupt', () => {
beforeEach(() => {
mockSend.mockClear()
})
afterEach(() => {
cleanup()
})

it('sends sdk.interrupt when Escape is pressed on the container while running', () => {
const store = makeStore()
render(
<Provider store={store}>
<AgentChatView tabId="tab-1" paneId="pane-1" paneContent={basePaneContent} />
</Provider>
)
const container = screen.getByRole('region', { name: /chat/i })
fireEvent.keyDown(container, { key: 'Escape' })
expect(mockSend).toHaveBeenCalledWith({
type: 'sdk.interrupt',
sessionId: 'sess-1',
})
})

it('does not send sdk.interrupt when Escape is pressed while idle', () => {
const store = makeStore()
const idleContent = { ...basePaneContent, status: 'idle' as const }
render(
<Provider store={store}>
<AgentChatView tabId="tab-1" paneId="pane-1" paneContent={idleContent} />
</Provider>
)
const container = screen.getByRole('region', { name: /chat/i })
fireEvent.keyDown(container, { key: 'Escape' })
expect(mockSend).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'sdk.interrupt' })
)
})

it('does not send sdk.interrupt for non-Escape keys while running', () => {
const store = makeStore()
render(
<Provider store={store}>
<AgentChatView tabId="tab-1" paneId="pane-1" paneContent={basePaneContent} />
</Provider>
)
const container = screen.getByRole('region', { name: /chat/i })
fireEvent.keyDown(container, { key: 'a' })
expect(mockSend).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'sdk.interrupt' })
)
})
})
```

**Step 2: Run test to verify it fails**

Run: `npx vitest run test/unit/client/components/agent-chat/AgentChatView-interrupt.test.tsx`

Expected: FAIL — the container doesn't have `onKeyDown` or `tabIndex` yet, so `fireEvent.keyDown` on the container won't trigger any interrupt. (Note: may also fail with pre-existing React `act()` error.)

**Step 3: Add container-level onKeyDown and tabIndex to AgentChatView**

In `src/components/agent-chat/AgentChatView.tsx`:

1. Add a `handleContainerKeyDown` callback (near line 237, after `handleContainerPointerUp`):

```tsx
const handleContainerKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape' && isRunning) {
e.preventDefault()
handleInterrupt()
}
}, [isRunning, handleInterrupt])
```

Note: `isRunning` is derived at line 305 (`const isRunning = paneContent.status === 'running'`). The callback references `isRunning` and `handleInterrupt`, both of which are defined before the return statement. Place the callback definition after line 305 (after `isRunning` is derived) so the dependency is available.

2. Update the outer `<div>` on line 378 to add `tabIndex={-1}` and `onKeyDown`:

Change:
```tsx
<div className={cn('h-full w-full flex flex-col', hidden ? 'tab-hidden' : 'tab-visible')} role="region" aria-label={`${providerLabel} Chat`} onPointerUp={handleContainerPointerUp}>
```

To:
```tsx
<div className={cn('h-full w-full flex flex-col', hidden ? 'tab-hidden' : 'tab-visible')} role="region" aria-label={`${providerLabel} Chat`} tabIndex={-1} onKeyDown={handleContainerKeyDown} onPointerUp={handleContainerPointerUp}>
```

**Step 4: Run test to verify it passes**

Run: `npx vitest run test/unit/client/components/agent-chat/AgentChatView-interrupt.test.tsx`

Expected: PASS (or pre-existing React act() failure — structurally verify the test is correct)

**Step 5: Commit**

```bash
git add src/components/agent-chat/AgentChatView.tsx test/unit/client/components/agent-chat/AgentChatView-interrupt.test.tsx
git commit -m "fix: add container-level Escape handler so interrupt works regardless of focus"
```

---

### Task 2: Remove redundant Escape handler from ChatComposer

Now that the container handles Escape, the textarea-level handler is redundant. Remove it to avoid double-firing.

**Files:**
- Modify: `src/components/agent-chat/ChatComposer.tsx:41-44`
- Modify: `src/components/agent-chat/ChatComposer.tsx:9` (props interface)
- Modify: `src/components/agent-chat/ChatComposer.tsx:17` (destructured props)
- Modify: `src/components/agent-chat/ChatComposer.tsx:45` (useCallback deps)
- Modify: `test/unit/client/components/agent-chat/ChatComposer.test.tsx:58-76`

**Step 1: Update the ChatComposer tests**

The two Escape-specific tests in `ChatComposer.test.tsx` (lines 58-76) should be removed since Escape handling is now the container's responsibility (tested in Task 1). The `onInterrupt` prop remains because the Stop button still uses it.

Remove the two tests:
- `'calls onInterrupt when Escape is pressed while running'` (lines 58-66)
- `'does not call onInterrupt when Escape is pressed while not running'` (lines 68-76)

**Step 2: Run tests to verify the removed tests no longer exist**

Run: `npx vitest run test/unit/client/components/agent-chat/ChatComposer.test.tsx`

Expected: 7 tests (down from 9). The stop-button click test still exercises `onInterrupt`.

**Step 3: Remove Escape handling from ChatComposer.tsx**

In `src/components/agent-chat/ChatComposer.tsx`, remove the `isRunning` dependency from `handleKeyDown`:

Change the `handleKeyDown` callback (lines 36-45) from:
```tsx
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
if (e.key === 'Escape' && isRunning) {
e.preventDefault()
onInterrupt()
}
}, [handleSend, isRunning, onInterrupt])
```

To:
```tsx
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}, [handleSend])
```

Also remove `isRunning` from the `ChatComposerProps` interface (line 13) and the destructured props (line 17), since it's no longer needed by ChatComposer. **Keep `onInterrupt`** — it's still used by the Stop button (line 77).

Remove line 13: `isRunning?: boolean`

Update line 17 from:
```tsx
function ChatComposer({ onSend, onInterrupt, disabled, isRunning, placeholder }, ref) {
```
To:
```tsx
function ChatComposer({ onSend, onInterrupt, disabled, placeholder }, ref) {
```

Update the JSX that conditionally renders the Stop button (line 74). It currently checks `isRunning` which we're removing from props. This needs to come from a new prop or we need to keep `isRunning`.

**Wait — reconsider.** `isRunning` is also used on line 74 to toggle between the Stop button and Send button. We need to keep `isRunning` as a prop for that UI toggle. Only remove the Escape handler from `handleKeyDown`, not the `isRunning` prop.

Revised change — just remove the Escape block from `handleKeyDown`:

```tsx
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}, [handleSend])
```

And remove `isRunning` and `onInterrupt` from the `useCallback` deps since they're no longer referenced in the callback.

**Step 4: Update AgentChatView to stop passing isRunning to ChatComposer for Escape**

No change needed — `AgentChatView` still passes `isRunning` and `onInterrupt` to `ChatComposer` for the Stop button toggle. This is correct.

**Step 5: Run all agent-chat tests**

Run: `npx vitest run test/unit/client/components/agent-chat/`

Expected: All tests pass (structurally correct; may have pre-existing act() failures).

**Step 6: Commit**

```bash
git add src/components/agent-chat/ChatComposer.tsx test/unit/client/components/agent-chat/ChatComposer.test.tsx
git commit -m "refactor: remove redundant Escape handler from ChatComposer textarea"
```

---

### Task 3: Verify no regressions and clean up

**Step 1: Run the full test suite**

Run: `npm test`

Expected: Same pass/fail counts as baseline (no new failures introduced).

**Step 2: Run lint**

Run: `npm run lint`

Expected: No new lint errors. The `tabIndex={-1}` on a `<div>` with `role="region"` and `onKeyDown` is valid per jsx-a11y rules (interactive handlers on focusable elements).

**Step 3: Manual smoke test (if possible)**

If the dev server can be started in the worktree, open Freshclaude, send a prompt that generates a long response, click somewhere in the message area (not the textarea), then press Escape. The generation should stop.

**Step 4: Final commit (if any lint/cleanup needed)**

```bash
git add -A
git commit -m "chore: lint and cleanup"
```
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion src/components/agent-chat/AgentChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,14 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag

const isInteractive = paneContent.status === 'idle' || paneContent.status === 'connected'
const isRunning = paneContent.status === 'running'

const handleContainerKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape' && isRunning) {
e.preventDefault()
handleInterrupt()
Comment on lines +308 to +310

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore Escape from nested dialogs before interrupting session

This handler interrupts any running session for every bubbled Escape keydown inside the pane, including when the user is trying to dismiss the settings popover or close a select/menu. AgentChatSettings already binds Escape-to-close behavior, but the container onKeyDown runs earlier in the bubble path, so pressing Escape while settings are open will now also send sdk.interrupt and stop generation unexpectedly. Please scope this shortcut to contexts where Escape is intended to mean “interrupt” (e.g., composer/message area) or explicitly skip dialog/form controls.

Useful? React with 👍 / 👎.

}
}, [isRunning, handleInterrupt])

const pendingPermissions = session ? Object.values(session.pendingPermissions) : []
const pendingQuestions = session ? Object.values(session.pendingQuestions) : []
const hasWaitingItems = pendingPermissions.length > 0 || pendingQuestions.length > 0
Expand Down Expand Up @@ -375,7 +383,7 @@ export default function AgentChatView({ tabId, paneId, paneContent, hidden }: Ag
const collapseThreshold = Math.max(0, turnItems.length - RECENT_TURNS_FULL)

return (
<div className={cn('h-full w-full flex flex-col', hidden ? 'tab-hidden' : 'tab-visible')} role="region" aria-label={`${providerLabel} Chat`} onPointerUp={handleContainerPointerUp}>
<div className={cn('h-full w-full flex flex-col', hidden ? 'tab-hidden' : 'tab-visible')} role="region" aria-label={`${providerLabel} Chat`} tabIndex={-1} onKeyDown={handleContainerKeyDown} onPointerUp={handleContainerPointerUp}>
{/* Status bar */}
<div className="flex items-center justify-between px-3 py-1.5 border-b text-xs text-muted-foreground">
<span>
Expand Down
6 changes: 1 addition & 5 deletions src/components/agent-chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,7 @@ const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function
e.preventDefault()
handleSend()
}
if (e.key === 'Escape' && isRunning) {
e.preventDefault()
onInterrupt()
}
}, [handleSend, isRunning, onInterrupt])
}, [handleSend])

const handleInput = useCallback(() => {
const el = textareaRef.current
Expand Down
Loading