diff --git a/web/src/App.tsx b/web/src/App.tsx
index a06a64e8..7909c8ad 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -37,6 +37,7 @@ import {
loadSession,
loadWorkspaceState,
replaySession,
+ startNewChat,
} from './app/store'
import { bridgeWS } from './app/wsBridge'
import { useChatRuntime } from './app/runtime'
@@ -116,18 +117,22 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
- // Global keyboard shortcuts: ⌘K (command palette), ⌘N (new chat), Esc (close
- // overlays). Mirrors the Vue App.vue shortcut wiring.
+ // Global keyboard shortcuts: ⌘K (command palette), ⌘N / ⇧⌘O (new chat), Esc
+ // (close overlays). ⌘N is reserved by browsers (new window) so it only fires
+ // in the desktop app; ⇧⌘O is interceptable everywhere and is the shortcut
+ // shown in the UI.
useEffect(() => {
function onKey(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey
- if (meta && e.key === 'k') {
+ if (meta && e.key === 'k' && !e.shiftKey) {
e.preventDefault()
dispatch(uiActions.setPaletteOpen(true))
- } else if (meta && e.key === 'n') {
+ } else if (meta && !e.shiftKey && e.key === 'n') {
e.preventDefault()
- // New chat: clear + reset session + switch to chat view.
- dispatch(loadSession('')) // empty → new session flow handled in Sidebar
+ void dispatch(startNewChat())
+ } else if (meta && e.shiftKey && e.key.toLowerCase() === 'o') {
+ e.preventDefault()
+ void dispatch(startNewChat())
} else if (meta && e.key === ',') {
e.preventDefault()
dispatch(uiActions.setSettingsOpen(true))
@@ -207,7 +212,7 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels'
})
}, [rightPanelOpen])
- // Panel keyboard shortcuts: ⇧⌘P (plan), ⇧⌘E (files), ⇧⌘G (changes), ⌘` (terminal).
+ // Panel keyboard shortcuts: ⇧⌘P (plan), ⇧⌘E (files), ⇧⌘G (changes), ⌘` / ⌘J (terminal).
useEffect(() => {
function onKey(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey
@@ -217,7 +222,9 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'channels'
e.preventDefault(); togglePanel('files')
} else if (meta && e.shiftKey && e.key.toLowerCase() === 'g') {
e.preventDefault(); togglePanel('changes')
- } else if (meta && e.key === '`') {
+ } else if (meta && !e.shiftKey && (e.key === '`' || e.key.toLowerCase() === 'j')) {
+ // ⌘` never reaches the page on macOS (OS window cycling), so ⌘J is the
+ // alias shown in the UI. `!e.shiftKey` keeps ⇧⌘J (DevTools) intact.
e.preventDefault(); togglePanel('terminal')
}
}
diff --git a/web/src/app/store.ts b/web/src/app/store.ts
index f465f260..951648ef 100644
--- a/web/src/app/store.ts
+++ b/web/src/app/store.ts
@@ -1059,6 +1059,24 @@ export const loadSession = createAsyncThunk(
},
)
+/**
+ * Start a fresh chat: clear the timeline, switch to the chat view, and ask the
+ * backend for a new session id. Shared by the Sidebar "new task" button and the
+ * ⌘N / ⇧⌘O keyboard shortcuts. The empty session stays out of the sidebar until
+ * the first user message (backend only indexes then).
+ */
+export const startNewChat = createAsyncThunk('session/startNew', async (_, { dispatch }) => {
+ dispatch(chatActions.clearChat())
+ dispatch(sessionActions.setCurrentSession(''))
+ dispatch(uiActions.setView('chat'))
+ try {
+ const resp = await api.newSession()
+ dispatch(sessionActions.setCurrentSession(resp.session_id))
+ } catch {
+ // surfaced via health/gate
+ }
+})
+
export const replaySession = createAsyncThunk(
'session/replay',
async (uuid: string, { dispatch }) => {
diff --git a/web/src/components/ChatInput.tsx b/web/src/components/ChatInput.tsx
index 88d0390a..a92809e6 100644
--- a/web/src/components/ChatInput.tsx
+++ b/web/src/components/ChatInput.tsx
@@ -34,7 +34,7 @@ import {
HandRaisedIcon,
ShieldExclamationIcon,
ClipboardDocumentListIcon,
- BoltIcon,
+ ViewfinderCircleIcon,
PlusIcon,
PaperClipIcon,
XMarkIcon,
@@ -801,7 +801,7 @@ export function ChatInput({ onSent, pickerPlacement = 'top', elevated = false }:
goalArmed ? 'bg-[var(--neutral-wash)] text-[var(--color-foreground)]' : ''
}`}
>
-