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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
@@ -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 });
|
||||
}, []);
|
||||
};
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user