Merge remote-tracking branch 'origin/main' into feat/nested-git-repos

# Conflicts:
#	packages/ui/src/components/views/GitView.tsx
#	packages/ui/src/stores/DOCUMENTATION.md
#	packages/ui/src/stores/useGitStore.ts
This commit is contained in:
jaygupta17
2026-08-30 09:48:35 +05:30
640 changed files with 46571 additions and 5636 deletions
@@ -1,6 +1,14 @@
import { expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import { hasOpenDropdown } from './keyboard-shortcut-dom';
import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
const domWindow = new Window();
Object.assign(globalThis, {
document: domWindow.document,
HTMLElement: domWindow.HTMLElement,
KeyboardEvent: domWindow.KeyboardEvent,
});
test('does not treat an unrelated visible listbox as an open dropdown', () => {
const promptNavigator = {} as Element;
@@ -28,3 +36,54 @@ test('detects an open select popup', () => {
expect(hasOpenDropdown(root)).toBe(true);
});
test('stops IME Escape before an open dropdown dismiss listener', () => {
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false);
expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false);
});
test('treats inputs, textareas, selects, and contenteditable elements as editable targets', () => {
expect(isEditableEventTarget(document.createElement('input'))).toBe(true);
expect(isEditableEventTarget(document.createElement('textarea'))).toBe(true);
expect(isEditableEventTarget(document.createElement('select'))).toBe(true);
const editableDiv = document.createElement('div');
Object.defineProperty(editableDiv, 'isContentEditable', { value: true });
expect(isEditableEventTarget(editableDiv)).toBe(true);
});
test('does not treat a plain element or non-element target as editable', () => {
expect(isEditableEventTarget(document.createElement('div'))).toBe(false);
expect(isEditableEventTarget(document.createElement('button'))).toBe(false);
expect(isEditableEventTarget(null)).toBe(false);
});
// Both digit shortcuts (switch_context_surface and switch_session_tab) gate on
// isEditableEventTarget(event.target). switch_session_tab's default prefix is a
// bare modifier, so plain ctrl/cmd+1 reaches the handler while the composer has
// focus; the guard only holds if a dispatched keydown reports the focused
// textarea as its target rather than the element the listener sits on (#2689).
test('reports the focused editable element as the target of a bubbled ctrl/cmd+digit keydown', () => {
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
let observedTarget: EventTarget | null = null;
const listener = (event: Event) => {
observedTarget = event.target;
};
document.addEventListener('keydown', listener);
textarea.dispatchEvent(new KeyboardEvent('keydown', {
key: '1',
metaKey: true,
bubbles: true,
}));
document.removeEventListener('keydown', listener);
textarea.remove();
expect(observedTarget).toBe(textarea);
expect(isEditableEventTarget(observedTarget)).toBe(true);
});
@@ -6,3 +6,19 @@ const OPEN_DROPDOWN_SELECTOR = [
export function hasOpenDropdown(root: ParentNode = document): boolean {
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
}
export function shouldStopDropdownImeEscape(
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
dropdownOpen: boolean,
): boolean {
return dropdownOpen
&& event.key === 'Escape'
&& (event.isComposing || event.keyCode === 229);
}
export function isEditableEventTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tagName = target.tagName;
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
}
+9 -15
View File
@@ -13,40 +13,34 @@
import React from 'react';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectContextOwner } from '@/hooks/useProjectContextOwner';
/**
* The directory is a parameter rather than read from `useEffectiveDirectory`,
* because this runs above `SyncProvider` — that hook reads the sync context and
* throws outside it, which took the whole app down with a blank window.
*/
const AGENT_MEMORY_FRESH_MS = 60_000;
export const useAgentMemorySync = (directory: string | null): void => {
const enabled = useUIStore((state) => (
state.agentMemoryFeatureAvailable && state.agentMemoryToolEnabled
));
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const effectiveDirectory = directory ?? '';
const load = useAgentMemoryStore((state) => state.load);
const owner = useProjectContextOwner(directory);
const projectPath = owner?.path ?? null;
const projectPath = React.useMemo(() => {
if (!effectiveDirectory) {
return null;
}
const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, effectiveDirectory);
return resolved?.path ?? null;
}, [availableWorktreesByProject, effectiveDirectory, projects]);
// The owner re-resolves on every directory switch; entries loaded moments
// ago for the same project are still current, and the change event below
// forces a re-read when the agent writes memory.
React.useEffect(() => {
if (!enabled) {
return;
}
void load(projectPath);
void load(projectPath, { maxAgeMs: AGENT_MEMORY_FRESH_MS });
}, [enabled, load, projectPath]);
// The agent writes memory mid-turn through its own tool, so the index for the
+226 -25
View File
@@ -4,13 +4,22 @@ import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
import type { TimelineRevealGate } from '@/components/chat/timelineRevealGate';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveRealContentEndOffset,
resolveTimelineIsAtEnd,
TIMELINE_FOLLOW_REARM_THRESHOLD_PX,
type TimelineListMeasurementState,
type TimelineScrollMode,
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
import {
isFollowReleaseKey,
isMiddleButtonPan,
nestedScrollableConsumesWheelUp,
} from '@/components/chat/lib/scroll/timelineScrollIntent';
// ──────────────────────────────────────────────────────────────────────────
// Chat timeline scroll ownership.
@@ -68,9 +77,20 @@ interface UseChatTimelineScrollOptions {
// Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null;
// True while the session is producing output. Follow corrections glide
// only then. Outside a live stream — entering a session, a tab becoming
// active, rows re-measuring after a switch — the viewport must land on
// the end instantly: an animated catch-up scrolls visibly through the
// conversation and gets cut short by the next measurement.
sessionIsWorking: boolean;
// Reveal gate of the session being opened. Held until the viewport is
// pinned to the end, so the session is never shown scrolled to the top.
revealGate?: TimelineRevealGate | null;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
// The live scroll element, as state, so effects that must re-bind when the
@@ -114,8 +134,12 @@ export const useChatTimelineScroll = ({
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
sessionIsWorking,
revealGate = null,
onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<TimelineListHandle | null>(null);
@@ -129,6 +153,8 @@ export const useChatTimelineScroll = ({
// True after a real gesture until an explicit opt back in; drives the
// overlay scrollbar suppression instead of the anchor's mere existence.
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
const userOwnsScrollRef = React.useRef(userOwnsScroll);
userOwnsScrollRef.current = userOwnsScroll;
const modeRef = React.useRef<TimelineScrollMode>('following-end');
const isAtEndRef = React.useRef(true);
@@ -314,6 +340,14 @@ export const useChatTimelineScroll = ({
}
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
// User preference: with auto-follow off, streaming growth never moves the
// viewport. Sending from the live edge still parks the new message at the
// top, but no glide or end-follow correction runs afterwards; sending from
// mid-history leaves the viewport untouched.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// Sending arms the anchor. The message id is not known here (the optimistic
// row is created by the store), so the next new user message id claims it.
// Whether the send-time anchor positioning may animate. Sending from the
@@ -324,6 +358,11 @@ export const useChatTimelineScroll = ({
const anchorPositionInstantRef = React.useRef(false);
const scrollToBottomOnSend = React.useCallback(() => {
// With auto-follow off, a reader who scrolled away from the end stays
// exactly where they are: the sent message is not anchored and the
// scroll-to-bottom pill (already showing) leads to it. From the live
// edge, sending anchors the new turn as usual.
if (!streamingAutoFollowEnabledRef.current && !isAtEndRef.current) return;
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
setUserOwnsScroll(false);
@@ -536,20 +575,16 @@ export const useChatTimelineScroll = ({
first: null,
second: null,
});
// User preference: with auto-follow off, streaming growth never moves the
// viewport — the anchored user message still parks at the top on send, but
// no glide or end-follow correction runs afterwards.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// While the list width is resizing, every pinning write fights the
// per-frame row re-measure and the pinned viewport shakes. Corrections
// stand down for the whole resize and the visible content is held by the
// list's size compensation instead. Deliberately NO snap back to the end
// afterwards: a slow drag settles repeatedly, and each snap reads as the
// very jump this suspension removes — geometry changed, staying where the
// reader is beats re-asserting the edge.
// afterwards for a mid-conversation reader: a slow drag settles
// repeatedly, and each snap reads as the very jump this suspension
// removes. A reader who WAS at the end is the exception — after rows
// re-wrap, stale cached sizes can leave a large phantom gap below the
// last row, so re-asserting the end once on settle is what "staying
// where the reader is" means for them.
const widthResizingRef = React.useRef(false);
React.useEffect(() => {
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
@@ -569,6 +604,27 @@ export const useChatTimelineScroll = ({
quietTimer = setTimeout(() => {
quietTimer = null;
widthResizingRef.current = false;
if (isAtEndRef.current && pendingAnchorRef.current === null) {
// Not scrollToEnd: the list's end offset comes from the
// total content length, which still carries pre-wrap row
// sizes (and any reserved anchored end space) right after a
// width change. Landing there parks the last row near the
// top of the viewport with a blank tail below it. Target
// the measured bottom of the last real row instead.
const list = listRef.current;
const state = list?.getState();
const offset = state
? resolveRealContentEndOffset({
state,
composerOverlayHeight: composerOverlayHeightRef.current,
})
: null;
if (list && offset !== null) {
void list.scrollToOffset({ offset, animated: false });
} else {
void list?.scrollToEnd({ animated: false });
}
}
}, 350);
});
observer.observe(scrollNode);
@@ -578,17 +634,98 @@ export const useChatTimelineScroll = ({
};
}, [scrollNode]);
// Keep the live edge in view after content growth. Within a viewport of
// the end the remaining distance is glided so a revealed block and the
// scroll read as one motion; further behind, the viewport first jumps to
// one screen above the end and glides only that last screen, so the
// reader is never left staring at a gap several screens tall. Writes go
// to the scroll node directly: routing each chunk through the list's
// scrollToEnd bookkeeping roughly doubled frame production when measured.
// A user gesture interrupts the native smooth scroll on its own, and the
// gesture handler drops live follow so no later correction re-engages.
const followEnd = React.useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const end = node.scrollHeight - node.clientHeight;
const distance = end - node.scrollTop;
if (distance <= 1) return;
if (!sessionIsWorkingRef.current) {
node.scrollTop = end;
return;
}
if (distance > node.clientHeight) {
node.scrollTop = end - node.clientHeight;
}
node.scrollTo({ top: end, behavior: 'smooth' });
}, []);
const onTimelineDataChange = React.useCallback(() => {
if (widthResizingRef.current) return;
if (!streamingAutoFollowEnabledRef.current) return;
// Stranded-viewport rescue, independent of any follow mode or
// preference: when off-screen size estimates settle smaller than
// estimated, the measured content can end ABOVE the viewport while
// the scroll offset stays at the stale end — the reader faces a blank
// phantom tail with every row out of reach above. That state is never
// intentional, so it is corrected even when auto-follow is off. Only
// a fully blank viewport qualifies; partial visibility is left alone.
if (!userOwnsScrollRef.current) {
const list = listRef.current;
if (list) {
const state = list.getState();
const lastIndex = state.data.length - 1;
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
if (lastBottom !== null && state.scroll > lastBottom) {
const offset = resolveRealContentEndOffset({
state,
composerOverlayHeight: composerOverlayHeightRef.current,
extraInset: CHAT_LIST_ANCHOR_OFFSET,
});
if (offset !== null) {
void list.scrollToOffset({ offset, animated: false });
return;
}
}
}
}
if (!streamingAutoFollowEnabledRef.current) {
// With auto-follow off nothing moves the viewport, so a growing
// reply slides below the visible area without a single scroll
// event — and the at-end transition that offers the pill never
// fires. Content growth is the signal here: once the real last
// row extends past what the composer leaves visible, the reader
// is factually behind and the pill must say so.
const list = listRef.current;
if (list && isAtEndRef.current) {
const state = list.getState();
const lastIndex = state.data.length - 1;
const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null;
if (lastBottom !== null) {
const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current;
if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) {
isAtEndRef.current = false;
setIsPinned(false);
scheduleShowScrollButton();
}
}
}
return;
}
if (!isLiveFollowActive()) return;
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
// growth on its own — including a tail row growing in place — and
// releases when the user scrolls away. Following the end therefore
// needs no correction here; this handler only serves the
// anchored-turn glide below.
if (modeRef.current === 'following-end') return;
// Following the end is owned here, not left to the list's
// maintainScrollAtEnd. The list's animated maintain is single-flight:
// growth that lands while a glide is still in flight is dropped until
// the next trigger, and its re-pin threshold is a tenth of the
// viewport. In a narrow viewport (the VS Code sidebar) one revealed
// block is several viewports tall, so every block left the reader a
// second behind and multiple screens above the live edge — measured
// at 45% of the stream time spent 500-1600px behind at 420x640.
if (modeRef.current === 'following-end') {
followEnd();
return;
}
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
@@ -637,7 +774,7 @@ export const useChatTimelineScroll = ({
});
});
}, [isLiveFollowActive]);
}, [followEnd, isLiveFollowActive, scheduleShowScrollButton]);
// The streaming tail grows inside one row without changing the entries
// array, so data-change callbacks are silent for the entire stream. The
@@ -679,8 +816,12 @@ export const useChatTimelineScroll = ({
onManualNavigationRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && canScrollUp()) gesture();
// Scrolling toward the end is not opting out of follow, and an
// upward wheel that a nested scroller still consumes never
// reaches the timeline.
if (event.deltaY < 0 && !nestedScrollableConsumesWheelUp(scrollNode, event.target) && canScrollUp()) {
gesture();
}
};
// Touch mirrors wheel by finger direction, not by having already left
// the end: while a stream keeps re-pinning the viewport, waiting for
@@ -704,14 +845,19 @@ export const useChatTimelineScroll = ({
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end.
// A middle-button pan scrolls without wheel events (and is the
// only scroll gesture for wheel-less mice), so the press is the
// opt-out. Otherwise the scrollbar track is the scroll node
// itself; a tap on a row only breaks follow when the viewport
// already left the end.
if (isMiddleButtonPan(scrollNode, event)) {
if (canScrollUp()) gesture();
return;
}
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
gesture();
}
if (isFollowReleaseKey(event) && canScrollUp()) gesture();
};
const handleScroll = () => {
queueSave();
@@ -738,6 +884,61 @@ export const useChatTimelineScroll = ({
};
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── entry pin ───────────────────────────────────────────────────────────
// An opened session is shown once, already at its end: the reveal gate is
// held until the viewport sits on the end, and the pin is one instant
// write. The list lays its rows out before the first frame, so this
// resolves within a frame; the gate's own cap bounds the wait.
React.useLayoutEffect(() => {
if (!currentSessionKey || !scrollNode) return;
const releaseReveal = revealGate?.hold() ?? null;
let frame: number | null = null;
const settle = () => {
frame = null;
if (!userOwnsScrollRef.current && modeRef.current === 'following-end') {
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
}
releaseReveal?.();
};
frame = requestAnimationFrame(settle);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
releaseReveal?.();
};
}, [currentSessionKey, revealGate, scrollNode]);
// ── pinned end ──────────────────────────────────────────────────────────
// "At the end" is an invariant, not a one-time scroll: while the reader
// sits on the end of a session that is not producing output, any growth
// of the content (a footer that decides to render, a row re-measured)
// keeps the end in view with one instant write. Output growth belongs to
// followEnd, which glides.
React.useEffect(() => {
if (!scrollNode || typeof MutationObserver === 'undefined') return;
const content = scrollNode.firstElementChild;
if (!content) return;
const pin = () => {
if (sessionIsWorkingRef.current) return;
if (userOwnsScrollRef.current || !isAtEndRef.current || modeRef.current !== 'following-end') return;
const end = scrollNode.scrollHeight - scrollNode.clientHeight;
if (end - scrollNode.scrollTop > 1) scrollNode.scrollTop = end;
};
// A MutationObserver runs as a microtask right after the list writes
// its layout (row positions, container height), before the frame is
// painted, so the pin lands in the same frame as the growth. A
// ResizeObserver would only see the container a rendering step later
// and let one frame paint with the end out of view.
const mutations = new MutationObserver(pin);
mutations.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(pin);
resizes?.observe(content);
return () => {
mutations.disconnect();
resizes?.disconnect();
};
}, [scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => {
+11 -1
View File
@@ -3,6 +3,7 @@ import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract';
import { useSessionDirectory } from '@/sync/sync-context';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { getChatsRootForHome } from '@/lib/chatDirectories';
/**
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
@@ -11,7 +12,10 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
* 1. Worktree metadata path (for worktree sessions)
* 2. Session directory (for active sessions)
* 3. Draft session directoryOverride (when creating a new session)
* 4. Fallback directory from DirectoryStore
* 4. For a Chat draft, the prepared chat directory or the managed Chats root —
* never the project the app was on before, which would leak that
* project's files, commands, and skills into the chat
* 5. Fallback directory from DirectoryStore
*
* This ensures that tabs show content from the correct project directory
* even when a draft session is being created.
@@ -23,6 +27,7 @@ export const useEffectiveDirectory = (): string | undefined => {
const worktreeAttachment = useSessionWorktreeStore((s) => currentSessionId ? s.getAttachment(currentSessionId) : undefined);
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
// If we have an active session, use its directory
if (currentSessionId) {
@@ -44,6 +49,11 @@ export const useEffectiveDirectory = (): string | undefined => {
return (newSessionDraft.bootstrapPendingDirectory || newSessionDraft.directoryOverride) ?? undefined;
}
if (newSessionDraft?.open && newSessionDraft.target === 'chat') {
const chatDirectory = newSessionDraft.preparedChatDirectory ?? getChatsRootForHome(homeDirectory);
if (chatDirectory) return chatDirectory;
}
// Fall back to the global directory
return fallbackDirectory ?? undefined;
};
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from 'bun:test';
import type { ShortcutHandler } from '@/lib/shortcuts';
import type { ShortcutBindings } from './useKeybind';
const handler: ShortcutHandler = () => {};
const validBindings = {
open_session_list: handler,
};
const mixedBindingsWithTypo = {
open_session_list: handler,
open_session_lsit: handler,
};
const acceptedBindings: ShortcutBindings<typeof validBindings> = validBindings;
// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID.
const rejectedBindings: ShortcutBindings<typeof mixedBindingsWithTypo> = mixedBindingsWithTypo;
void rejectedBindings;
test('accepts bindings whose IDs are declared in the shortcut schema', () => {
expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']);
});
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts';
export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void {
const handlerRef = React.useRef(handler);
handlerRef.current = handler;
React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]);
}
export type ShortcutBindings<
Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
> = Bindings & Record<Exclude<keyof Bindings, ShortcutActionId>, never>;
export function useKeybinds<
const Bindings extends Partial<Record<ShortcutActionId, ShortcutHandler>>,
>(bindings: ShortcutBindings<Bindings>): void {
const handlersRef = React.useRef(bindings);
handlersRef.current = bindings;
const actionIdsKey = Object.keys(bindings).sort().join('\0');
React.useEffect(() => {
const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[];
const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => {
const handler = handlersRef.current[actionId];
return handler ? handler(event) : false;
}));
return () => unregister.forEach((remove) => remove());
}, [actionIdsKey]);
}
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -14,10 +14,18 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
export interface LocalTTSSpeakOptions {
/** Kokoro speaker id (0-10) */
/** Catalog id of the local model to use; defaults to the server's default model. */
model?: string;
/** Speaker id within the model (Kokoro voices; Piper models have one) */
speakerId?: number;
/** Playback speed multiplier (1.0 = normal) */
speed?: number;
/**
* `'auto'`: the server picks a model and voice for the text's language.
* The language is judged on the whole message, not on each chunk sent for
* synthesis, so a short chunk cannot flip the voice mid-reply.
*/
language?: 'auto';
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
@@ -35,6 +43,8 @@ export interface UseLocalTTSReturn {
/** Target chunk size: big enough to amortize requests, small enough for low latency. */
const MIN_CHUNK_CHARS = 60;
const MAX_CHUNK_CHARS = 400;
// Enough of the message for language detection to see whole sentences.
const LANGUAGE_SAMPLE_CHARS = 2000;
/**
* Split text into sentence-aligned chunks for pipelined synthesis.
@@ -170,6 +180,7 @@ export function useLocalTTS(): UseLocalTTSReturn {
const session: PlaybackSession = { cancelled: false, abort: new AbortController() };
sessionRef.current = session;
const languageSample = options?.language === 'auto' ? text.slice(0, LANGUAGE_SAMPLE_CHARS) : undefined;
const fetchChunk = async (chunk: string): Promise<ArrayBuffer> => {
const response = await runtimeFetch('/api/dictation/tts/speak', {
@@ -177,8 +188,11 @@ export function useLocalTTS(): UseLocalTTSReturn {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: chunk,
model: options?.model,
...(typeof options?.speakerId === 'number' ? { speakerId: options.speakerId } : {}),
...(typeof options?.speed === 'number' ? { speed: options.speed } : {}),
language: options?.language,
languageSample,
}),
signal: session.abort.signal,
});
+7
View File
@@ -61,6 +61,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
const speechVolume = useConfigStore((state) => state.speechVolume);
const sayVoice = useConfigStore((state) => state.sayVoice);
const localTtsVoiceId = useConfigStore((state) => state.localTtsVoiceId);
const localTtsModelId = useConfigStore((state) => state.localTtsModelId);
const ttsFollowTextLanguage = useConfigStore((state) => state.ttsFollowTextLanguage);
const browserVoice = useConfigStore((state) => state.browserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
@@ -135,8 +137,10 @@ export function useMessageTTS(): UseMessageTTSReturn {
});
} else if (voiceProvider === 'local') {
await speakLocalTTS(sanitizedText, {
model: localTtsModelId,
speakerId: localTtsVoiceId,
speed: speechRate,
language: ttsFollowTextLanguage ? 'auto' : undefined,
onEnd: () => setIsPlaying(false),
onError: () => setIsPlaying(false),
});
@@ -145,6 +149,7 @@ export function useMessageTTS(): UseMessageTTSReturn {
await speakSayTTS(sanitizedText, {
voice: sayVoice,
rate: wordsPerMinute,
language: ttsFollowTextLanguage ? 'auto' : undefined,
onEnd: () => setIsPlaying(false),
onError: () => setIsPlaying(false),
});
@@ -187,6 +192,8 @@ export function useMessageTTS(): UseMessageTTSReturn {
speakSayTTS,
speakLocalTTS,
localTtsVoiceId,
localTtsModelId,
ttsFollowTextLanguage,
stop,
]);
@@ -1,97 +1,129 @@
import React from 'react';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { ShortcutDispatcher, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useKeybinds } from './useKeybind';
import { isEditableEventTarget } from './keyboard-shortcut-dom';
export const useMiniChatKeyboardShortcuts = () => {
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
if (!dispatcherRef.current) {
dispatcherRef.current = new ShortcutDispatcher({
registry: shortcutRegistry,
getBinding: (actionId) => getEffectiveShortcutCombo(
actionId,
useUIStore.getState().shortcutOverrides,
),
});
}
const dispatcher = dispatcherRef.current;
const cycleFavoriteModel = (delta: number): boolean | void => {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) return false;
const {
currentProviderId,
currentModelId,
setProvider,
setModel,
} = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex(
(favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId,
);
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
};
useKeybinds({
focus_input: () => {
focusChatInput();
},
new_mini_chat: () => {
if (!canUseElectronDesktopIPC()) return false;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: '',
projectId: null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
},
new_chat: () => {
const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput();
},
open_model_selector: () => {
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
},
cycle_thinking_variant: () => {
const configState = useConfigStore.getState();
if (configState.getCurrentModelVariants().length === 0) return false;
const nextVariantOverride = configState.cycleCurrentVariant();
const sessionId = useSessionUIStore.getState().currentSessionId;
const {
currentAgentName,
currentProviderId,
currentModelId,
} = useConfigStore.getState();
if (sessionId && currentAgentName && currentProviderId && currentModelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(
sessionId,
currentAgentName,
currentProviderId,
currentModelId,
nextVariantOverride,
);
}
},
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
});
React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
const handleKeyDown = (event: KeyboardEvent) => {
if (eventMatchesShortcut(event, combo('focus_input'))) {
event.preventDefault();
focusChatInput();
const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
if (!dispatcher.hasActivePrefix()) return;
// An unmodified completion key typed into an editable target is only a
// deliberate sequence when the prefix was armed from that same target;
// otherwise it is regular typing and must not be swallowed.
if (
!event.ctrlKey && !event.metaKey && !event.altKey
&& isEditableEventTarget(event.target)
&& dispatcher.getActivePrefixTarget() !== event.target
) {
dispatcher.clear();
return;
}
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
if (dispatcher.dispatchActivePrefix(event)) {
event.preventDefault();
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: '',
projectId: null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
return;
}
if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault();
const sessionState = useSessionUIStore.getState();
openNewSessionDraft(sessionState.currentSessionId && sessionState.currentSessionDirectory
? { directoryOverride: sessionState.currentSessionDirectory }
: undefined);
focusChatInput();
return;
}
if (eventMatchesShortcut(event, combo('open_model_selector'))) {
event.preventDefault();
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
return;
}
if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) {
const configState = useConfigStore.getState();
const variants = configState.getCurrentModelVariants();
if (variants.length === 0) {
return;
}
event.preventDefault();
configState.cycleCurrentVariant();
const nextVariant = useConfigStore.getState().currentVariant;
const sessionId = useSessionUIStore.getState().currentSessionId;
const agentName = useConfigStore.getState().currentAgentName;
const providerId = useConfigStore.getState().currentProviderId;
const modelId = useConfigStore.getState().currentModelId;
if (sessionId && agentName && providerId && modelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
}
return;
}
const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward'));
const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward'));
if (cyclesForward || cyclesBackward) {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) {
return;
}
event.preventDefault();
const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId);
const delta = cyclesForward ? 1 : -1;
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
event.stopPropagation();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
if (dispatcher.dispatch(event)) event.preventDefault();
};
const handleBlur = () => dispatcher.handleBlur();
window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [openNewSessionDraft, shortcutOverrides]);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
};
}, [dispatcher]);
};
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'bun:test';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { resolveProjectContextOwner } from './useProjectContextOwner';
const projects = [
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
];
describe('resolveProjectContextOwner', () => {
test('resolves a managed chat directory to the Chats root instead of the active project', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: '/Users/test/.config/openchamber/chats/2026-08-27/session-a',
activeProjectId: 'openchamber',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toEqual({
id: CHAT_DRAFT_PROJECT_ID,
path: '/Users/test/.config/openchamber/chats',
});
});
test('resolves a worktree session to its owning project', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map([
['/workspace/openchamber', [{
path: '/workspace/openchamber-feature',
projectDirectory: '/workspace/openchamber',
branch: 'feature',
label: 'feature',
}]],
]),
directory: '/workspace/openchamber-feature',
activeProjectId: null,
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
});
test('returns null for a recognized directory that owns nothing, instead of borrowing the active project', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: '/some/other/project',
activeProjectId: 'openchamber',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toBeNull();
});
test('falls back to the active project only when there is no directory at all', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: null,
activeProjectId: 'openchamber',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
});
test('never falls back to the first project when the active project is unknown', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: null,
activeProjectId: 'missing-project',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toBeNull();
});
});
@@ -0,0 +1,89 @@
import React from 'react';
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory } from '@/lib/chatDirectories';
import { normalizePath } from '@/lib/pathNormalization';
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
import type { ProjectRef } from '@/lib/projectContextApi';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import type { WorktreeMetadata } from '@/types/worktree';
import type { ProjectEntry } from '@/lib/api/types';
interface ProjectContextOwnerInput {
projects: ProjectEntry[];
worktreesByProject: Map<string, WorktreeMetadata[]>;
directory: string | null;
activeProjectId: string | null;
chatDraftOpen: boolean;
chatDraftTarget: 'chat' | 'project';
homeDirectory: string | null;
}
export const resolveProjectContextOwner = ({
projects,
worktreesByProject,
directory,
activeProjectId,
chatDraftOpen,
chatDraftTarget,
homeDirectory,
}: ProjectContextOwnerInput): ProjectRef | null => {
const chatsRoot = getChatsRootFromDirectory(directory) ?? getChatsRootForHome(homeDirectory);
const normalizedDirectory = normalizePath(directory);
const normalizedChatsRoot = normalizePath(chatsRoot);
const ownsChats = chatDraftOpen
? chatDraftTarget === 'chat'
: Boolean(normalizedDirectory && normalizedChatsRoot && (
normalizedDirectory === normalizedChatsRoot || normalizedDirectory.startsWith(`${normalizedChatsRoot}/`)
));
if (ownsChats && chatsRoot) {
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
}
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, directory);
if (sessionProject) {
return { id: sessionProject.id, path: sessionProject.path };
}
// A concrete directory that resolves to nothing owns nothing. Falling back
// to the active project here showed one project's knowledge under another
// project's name (the "plans open empty" bug), so the panel stays empty
// instead of lying. The active-project fallback is only for states with no
// directory at all, such as a new-session draft that has not landed yet.
if (normalizedDirectory) {
return null;
}
const activeProject = projects.find((project) => project.id === activeProjectId) ?? null;
return activeProject ? { id: activeProject.id, path: activeProject.path } : null;
};
/** The single owner used by Project knowledge and agent-memory synchronization. */
export const useProjectContextOwner = (directory: string | null): ProjectRef | null => {
const projects = useProjectsStore((state) => state.projects);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
const chatDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
const chatDraftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
return React.useMemo(() => resolveProjectContextOwner({
projects,
worktreesByProject,
directory,
activeProjectId,
chatDraftOpen,
chatDraftTarget,
homeDirectory,
}), [
activeProjectId,
chatDraftOpen,
chatDraftTarget,
directory,
homeDirectory,
projects,
worktreesByProject,
]);
};
+9 -2
View File
@@ -14,6 +14,7 @@ type ManifestSyncWindow = Window & {
};
const MAX_RECENT_SHORTCUTS = 3;
const MANIFEST_UPDATE_DELAY_MS = 2_000;
const normalizeRecentTitle = (value: string | undefined, fallback: string): string => {
if (typeof value !== 'string') {
@@ -86,7 +87,13 @@ export const usePwaManifestSync = () => {
return;
}
const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
// Rebuilding the manifest fetches it from the server. Shortcuts only
// matter to the installed-app menu, so the rebuild waits until the switch
// that changed them has settled instead of adding a request to it.
const timer = window.setTimeout(() => {
const win = window as ManifestSyncWindow;
win.__OPENCHAMBER_UPDATE_PWA_MANIFEST__?.();
}, MANIFEST_UPDATE_DELAY_MS);
return () => window.clearTimeout(timer);
}, [hasRecentShortcuts, signature]);
};
@@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { isRootScrollTarget, resetRootScroll } from './useRootScrollLock';
type FakeElement = EventTarget & { id: string; scrollTop: number; scrollLeft: number };
const element = (id: string): FakeElement => Object.assign(new EventTarget(), { id, scrollTop: 0, scrollLeft: 0 });
/** Installs a minimal stand-in for `document` for the duration of `run`. */
const withDocument = (setup: { root?: FakeElement }, run: () => void) => {
const fakeDocument = {
documentElement: element('html'),
body: element('body'),
getElementById: (id: string) => (setup.root && setup.root.id === id ? setup.root : null),
};
// The hook only reads documentElement/body/getElementById from `document`;
// this stand-in provides exactly those members for a DOM-less test process.
const hadDocument = 'document' in globalThis;
const previous = hadDocument ? globalThis.document : undefined;
Reflect.set(globalThis, 'document', fakeDocument);
try {
run();
} finally {
if (hadDocument) Reflect.set(globalThis, 'document', previous);
else Reflect.deleteProperty(globalThis, 'document');
}
};
describe('resetRootScroll', () => {
test('snaps every root scroll offset back to zero and reports the reset', () => {
const root = element('root');
withDocument({ root }, () => {
document.documentElement.scrollTop = 48;
document.body.scrollLeft = 12;
root.scrollTop = 200;
expect(resetRootScroll()).toBe(true);
expect(document.documentElement.scrollTop).toBe(0);
expect(document.body.scrollLeft).toBe(0);
expect(root.scrollTop).toBe(0);
});
});
test('reports nothing to do when the root is already at zero', () => {
withDocument({}, () => {
expect(resetRootScroll()).toBe(false);
});
});
});
describe('isRootScrollTarget', () => {
test('recognises the document, html and body as root scroll sources', () => {
withDocument({}, () => {
expect(isRootScrollTarget(document)).toBe(true);
expect(isRootScrollTarget(document.documentElement)).toBe(true);
expect(isRootScrollTarget(document.body)).toBe(true);
});
});
test('ignores scroll events from inner containers', () => {
withDocument({}, () => {
expect(isRootScrollTarget(element('chat-timeline'))).toBe(false);
});
});
});
@@ -0,0 +1,51 @@
import React from 'react';
/**
* The document root (`html`, `body`, `#root`) is `overflow: hidden` and must
* never scroll every scrollable area lives in a dedicated container. Chromium
* still scrolls hidden-overflow ancestors programmatically, most visibly when
* a textarea caret moves out of view (PageUp/PageDown in the prompt box, or a
* long prompt being typed) and the browser scrolls it into view. Once that
* happens the whole app shifts up, hides the title bar, and nothing the user
* does with the wheel or keyboard can scroll it back.
*
* Snap every root scroll straight back to zero.
*/
const rootScrollTargets = (): HTMLElement[] => {
const targets = [document.documentElement, document.body];
const appRoot = document.getElementById('root');
if (appRoot) targets.push(appRoot);
return targets;
};
export const resetRootScroll = (): boolean => {
let reset = false;
for (const target of rootScrollTargets()) {
if (target.scrollTop !== 0) {
target.scrollTop = 0;
reset = true;
}
if (target.scrollLeft !== 0) {
target.scrollLeft = 0;
reset = true;
}
}
return reset;
};
export const isRootScrollTarget = (target: EventTarget | null): boolean =>
target === document || rootScrollTargets().some((element) => element === target);
export const useRootScrollLock = (): void => {
React.useEffect(() => {
const handleScroll = (event: Event) => {
if (isRootScrollTarget(event.target)) resetRootScroll();
};
// Capture: the root's own scroll events don't bubble to inner listeners,
// and scroll events from inner containers are filtered out above.
document.addEventListener('scroll', handleScroll, { capture: true, passive: true });
resetRootScroll();
return () => document.removeEventListener('scroll', handleScroll, { capture: true });
}, []);
};
+3 -7
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
import { openSessionFromRoute } from '@/lib/router/openSessionFromRoute';
import type { RouteState, AppRouteState } from '@/lib/router';
import { resolveSettingsSlug } from '@/lib/settings/metadata';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
@@ -48,7 +49,6 @@ export function useRouter(): void {
const isApplyingRouteRef = React.useRef(false);
// Get store actions (stable references)
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
@@ -67,11 +67,7 @@ export function useRouter(): void {
try {
// 1. Apply session first (may trigger async operations)
if (route.sessionId) {
const currentSessionId = useSessionUIStore.getState().currentSessionId;
if (route.sessionId !== currentSessionId) {
const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId);
setCurrentSession(route.sessionId, directoryHint);
}
await openSessionFromRoute(route.sessionId);
}
// 2. Handle settings first because it is a full-screen overlay.
@@ -107,7 +103,7 @@ export function useRouter(): void {
isApplyingRouteRef.current = false;
}
},
[setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
[setSettingsDialogOpen, setSettingsPage, navigateToDiff]
);
/**
+3
View File
@@ -105,6 +105,8 @@ interface SpeakOptions {
voice?: string;
/** Speech rate in words per minute (defaults to 200) */
rate?: number;
/** `'auto'`: the server switches to a voice that speaks the text's language. */
language?: 'auto';
/** Callback when playback starts */
onStart?: () => void;
/** Callback when playback ends */
@@ -229,6 +231,7 @@ export function useSayTTS(options: UseSayTTSOptions = {}): UseSayTTSReturn {
text: text.trim(),
voice: options?.voice || 'Samantha',
rate: options?.rate || 200,
language: options?.language,
}),
signal: abortControllerRef.current.signal,
});
@@ -55,6 +55,8 @@ export interface SessionAssistState {
visibleRecap: string | null;
/** Suggestion text — fresh payload, session idle; caller still gates on input emptiness. */
suggestion: string | null;
/** False until the session record is in memory; the recap cannot be decided before that. */
sessionKnown: boolean;
}
export function useSessionAssistState(sessionId: string, directory?: string): SessionAssistState {
@@ -93,5 +95,6 @@ export function useSessionAssistState(sessionId: string, directory?: string): Se
assist,
visibleRecap: sessionRecapEnabled && assist && assist.recap && quietElapsed ? assist.recap : null,
suggestion: sessionSuggestionEnabled && assist && assist.suggestion ? assist.suggestion : null,
sessionKnown: session !== undefined && session !== null,
};
}
+15 -1
View File
@@ -22,6 +22,9 @@ export function useSessionGoal(sessionId: string, directory?: string): SessionGo
};
}
const OBJECTIVE_CONTENT_CACHE_MAX = 64;
const objectiveContentByFetchKey = new Map<string, Promise<string | null>>();
// Effective objective text for display. Inline goals return the metadata
// text directly; file-backed goals fetch the server-side file once per
// goal edit (keyed by id + updatedAt). Display-only: a failed fetch yields
@@ -37,7 +40,18 @@ export function useGoalObjectiveContent(sessionId: string, goal: SessionGoalPayl
return undefined;
}
let alive = true;
void fetchGoalObjectiveContent(sessionId).then((content) => {
// The key already names the goal edit, so a remount (every session switch
// remounts the strip) reuses the text instead of fetching the file again.
let request = objectiveContentByFetchKey.get(fetchKey);
if (!request) {
request = fetchGoalObjectiveContent(sessionId);
objectiveContentByFetchKey.set(fetchKey, request);
if (objectiveContentByFetchKey.size > OBJECTIVE_CONTENT_CACHE_MAX) {
const oldest = objectiveContentByFetchKey.keys().next().value;
if (oldest !== undefined) objectiveContentByFetchKey.delete(oldest);
}
}
void request.then((content) => {
if (alive) setFetched(content);
});
return () => {