perf: reduce UI render fanout and scroll jitter
- Cut broad render fanout across the app by replacing shared-store whole-object subscriptions with leaf selectors, memoizing hot chrome boundaries, and isolating disabled global providers from live session/message state. This keeps header controls, composer toolbars, side panels, and other non-hot UI surfaces from repainting on every assistant update or keystroke. - Rework sidebar session ordering so recent, project groups, and worktree groups derive from one ordering source while avoiding streaming-time thrash. The sidebar now uses a stabilized session snapshot, preserves structural identity for unchanged rows, reads live row status/details per session, and applies a one-shot sort bump on idle->busy instead of continuously resorting during activity. - Fix chat/input scroll instability by separating viewport-resize handling from message-growth handling, disabling conflicting native scroll anchoring, and stopping textarea autosize from collapsing on every growth keystroke. This removes the multiline typing jiggle during streaming and reduces unnecessary composer rerenders. - Also gate voice context wiring behind voice-mode enablement and codify the learned render/scroll/order anti-patterns in AGENTS.md so future changes avoid the same classes of regressions.
This commit is contained in:
@@ -264,6 +264,8 @@ These rules exist because violating them has caused measurable regressions (rend
|
|||||||
- **Update only the fields that changed.** Preserve references for untouched state branches.
|
- **Update only the fields that changed.** Preserve references for untouched state branches.
|
||||||
- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component.
|
- **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component.
|
||||||
- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
|
- **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child.
|
||||||
|
- **Do not subscribe shell/layout components to broad live collections.** If a shell only needs one field, entity, or derived flag, subscribe to that instead of the whole collection.
|
||||||
|
- **Treat provider roots as global hot paths.** A top-level provider must not subscribe to high-frequency data unless the feature is actually enabled and the subscription is essential.
|
||||||
|
|
||||||
### Zustand referential equality
|
### Zustand referential equality
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ Zustand skips re-renders when a selector returns the same reference (`Object.is`
|
|||||||
- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc.
|
- **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc.
|
||||||
- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions.
|
- **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions.
|
||||||
- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
|
- **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added.
|
||||||
|
- **For derived collections, preserve item identity when presentation-relevant fields are unchanged.** Reuse previous item references for unchanged rows/items and move high-frequency live fields to narrow per-item selectors.
|
||||||
|
|
||||||
### Store splitting
|
### Store splitting
|
||||||
|
|
||||||
@@ -306,6 +309,8 @@ A single store with N properties means every subscriber re-evaluates on every st
|
|||||||
|
|
||||||
- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
|
- **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time.
|
||||||
- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending.
|
- **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending.
|
||||||
|
- **Do not let text input state repaint unrelated chrome.** Typing should not force unrelated controls, menus, indicators, or toolbars to re-render on every keystroke.
|
||||||
|
- **Extract slow-changing chrome from hot input paths.** If controls do not depend on the current text value, move them behind memoized boundaries with stable callbacks.
|
||||||
|
|
||||||
### Bootstrap resilience
|
### Bootstrap resilience
|
||||||
|
|
||||||
@@ -316,6 +321,16 @@ A single store with N properties means every subscriber re-evaluates on every st
|
|||||||
|
|
||||||
- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints.
|
- **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints.
|
||||||
- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output.
|
- **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output.
|
||||||
|
- **Do not let viewport resizes masquerade as content growth.** Viewport-height changes must not trigger the same scroll compensation logic used for actual content growth.
|
||||||
|
- **Disable or narrow native/browser scroll anchoring when custom scroll logic exists.** Browser anchoring and app-managed pinning/follow logic will fight and produce jiggle.
|
||||||
|
- **Autosize textareas without transient collapse on growth.** Avoid `height='auto'` shrink/expand cycles on every character when the content only grew; this creates visible layout bounce.
|
||||||
|
|
||||||
|
### List ordering and view consistency
|
||||||
|
|
||||||
|
- **Do not sort structural lists directly from high-churn live fields.** If live updates are frequent, sorting directly from them causes reorder thrash and wide rerender cascades.
|
||||||
|
- **If live recency is required, freeze order during high-frequency updates and apply a one-shot reorder only at an intentional lifecycle edge.** Choose the lifecycle edge explicitly instead of letting every intermediate update reshuffle the UI.
|
||||||
|
- **Use one ordering source for all views of the same data.** Different views of the same entities must derive from the same ranked list or rank map; do not let each surface re-derive ordering independently.
|
||||||
|
- **Do not mix global snapshots and local live snapshots without an explicit reconciliation policy.** If multiple data sources feed one view, define which fields win and how they merge.
|
||||||
|
|
||||||
### Component isolation
|
### Component isolation
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ export const AgentMentionAutocomplete = React.forwardRef<AgentMentionAutocomplet
|
|||||||
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
|
||||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const ignoreTabClickRef = React.useRef(false);
|
const ignoreTabClickRef = React.useRef(false);
|
||||||
const { getVisibleAgents } = useConfigStore();
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const { agents: agentsWithMetadata, loadAgents } = useAgentsStore();
|
const agentsWithMetadata = useAgentsStore((state) => state.agents);
|
||||||
|
const loadAgents = useAgentsStore((state) => state.loadAgents);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (agentsWithMetadata.length === 0) {
|
if (agentsWithMetadata.length === 0) {
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ export const ChatContainer: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// UI store
|
// UI store
|
||||||
const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore();
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||||
|
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
||||||
|
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||||
|
|
||||||
// Streaming state
|
// Streaming state
|
||||||
const streamingMessageId = useStreamingStore(
|
const streamingMessageId = useStreamingStore(
|
||||||
@@ -516,9 +518,7 @@ export const ChatContainer: React.FC = () => {
|
|||||||
<ScrollShadow
|
<ScrollShadow
|
||||||
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
style={(timelineController.pendingRevealWork || timelineController.isLoadingOlder)
|
style={{ overflowAnchor: 'none' }}
|
||||||
? { overflowAnchor: 'none' }
|
|
||||||
: undefined}
|
|
||||||
observeMutations={false}
|
observeMutations={false}
|
||||||
hideTopShadow={isMobile && stickyUserHeader}
|
hideTopShadow={isMobile && stickyUserHeader}
|
||||||
data-scroll-shadow="true"
|
data-scroll-shadow="true"
|
||||||
|
|||||||
@@ -223,6 +223,370 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
|
|||||||
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
|
return PROJECT_COLOR_MAP[projectColor] ?? undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MemoModelControls = React.memo(ModelControls);
|
||||||
|
const MemoUnifiedControlsDrawer = React.memo(UnifiedControlsDrawer);
|
||||||
|
const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton);
|
||||||
|
const MemoMobileAgentButton = React.memo(MobileAgentButton);
|
||||||
|
const MemoMobileModelButton = React.memo(MobileModelButton);
|
||||||
|
const MemoStatusRow = React.memo(StatusRow);
|
||||||
|
|
||||||
|
type ComposerAttachmentControlsProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
isVSCode: boolean;
|
||||||
|
footerIconButtonClass: string;
|
||||||
|
iconSizeClass: string;
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
handleLocalFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void | Promise<void>;
|
||||||
|
handlePickLocalFiles: () => void;
|
||||||
|
handleOpenCommandMenu: () => void;
|
||||||
|
openIssuePicker: () => void;
|
||||||
|
openPrPicker: () => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) {
|
||||||
|
const {
|
||||||
|
isMobile,
|
||||||
|
isVSCode,
|
||||||
|
footerIconButtonClass,
|
||||||
|
iconSizeClass,
|
||||||
|
fileInputRef,
|
||||||
|
handleLocalFileSelect,
|
||||||
|
handlePickLocalFiles,
|
||||||
|
handleOpenCommandMenu,
|
||||||
|
openIssuePicker,
|
||||||
|
openPrPicker,
|
||||||
|
onOpenSettings,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-x-1.5">
|
||||||
|
{isMobile ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
'rounded-md',
|
||||||
|
'hover:bg-interactive-hover/40'
|
||||||
|
)}
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
if (event.pointerType === 'touch') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={handleOpenCommandMenu}
|
||||||
|
title="Commands"
|
||||||
|
aria-label="Commands"
|
||||||
|
>
|
||||||
|
<RiCommandLine className={cn(iconSizeClass)} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleLocalFileSelect}
|
||||||
|
accept="*/*"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
{isVSCode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={footerIconButtonClass}
|
||||||
|
onClick={handlePickLocalFiles}
|
||||||
|
title="Attach files"
|
||||||
|
aria-label="Attach files"
|
||||||
|
>
|
||||||
|
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={footerIconButtonClass}
|
||||||
|
title="Add attachment"
|
||||||
|
aria-label="Add attachment"
|
||||||
|
>
|
||||||
|
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(handlePickLocalFiles);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiAttachment2 />
|
||||||
|
Attach files
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(openIssuePicker);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiGithubLine />
|
||||||
|
Link GitHub Issue
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
requestAnimationFrame(openPrPicker);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiGitPullRequestLine />
|
||||||
|
Link GitHub PR
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onOpenSettings ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className={footerIconButtonClass}
|
||||||
|
title="Model and agent settings"
|
||||||
|
aria-label="Model and agent settings"
|
||||||
|
>
|
||||||
|
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, (prev, next) => (
|
||||||
|
prev.isMobile === next.isMobile
|
||||||
|
&& prev.isVSCode === next.isVSCode
|
||||||
|
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||||
|
&& prev.iconSizeClass === next.iconSizeClass
|
||||||
|
&& prev.onOpenSettings === next.onOpenSettings
|
||||||
|
));
|
||||||
|
|
||||||
|
type PermissionAutoAcceptButtonProps = {
|
||||||
|
footerIconButtonClass: string;
|
||||||
|
iconSizeClass: string;
|
||||||
|
permissionScopeSessionId: string | null;
|
||||||
|
permissionAutoAcceptEnabled: boolean;
|
||||||
|
handlePermissionAutoAcceptToggle: () => void;
|
||||||
|
withTooltip?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) {
|
||||||
|
const {
|
||||||
|
footerIconButtonClass,
|
||||||
|
iconSizeClass,
|
||||||
|
permissionScopeSessionId,
|
||||||
|
permissionAutoAcceptEnabled,
|
||||||
|
handlePermissionAutoAcceptToggle,
|
||||||
|
withTooltip = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const ariaLabel = permissionAutoAcceptEnabled
|
||||||
|
? 'Disable permission auto-accept'
|
||||||
|
: 'Enable permission auto-accept';
|
||||||
|
const tooltipLabel = permissionAutoAcceptEnabled
|
||||||
|
? 'Permission auto-accept: on'
|
||||||
|
: 'Permission auto-accept: off';
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePermissionAutoAcceptToggle}
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
'rounded-md hover:bg-transparent',
|
||||||
|
!permissionScopeSessionId && 'opacity-30',
|
||||||
|
)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
if (event.pointerType === 'touch') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-pressed={permissionAutoAcceptEnabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
title={ariaLabel}
|
||||||
|
>
|
||||||
|
{permissionAutoAcceptEnabled ? (
|
||||||
|
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
|
||||||
|
) : (
|
||||||
|
<RiShieldUserLine className={cn(iconSizeClass)} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!withTooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={600}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{button}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
{tooltipLabel}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type FocusModeButtonProps = {
|
||||||
|
footerIconButtonClass: string;
|
||||||
|
iconSizeClass: string;
|
||||||
|
isExpandedInput: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) {
|
||||||
|
const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={600}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
'rounded-md',
|
||||||
|
isExpandedInput
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
|
||||||
|
)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label="Toggle focus mode"
|
||||||
|
aria-pressed={isExpandedInput}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine className={cn(iconSizeClass)} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
<div className="flex flex-col gap-0.5 text-center">
|
||||||
|
<span>Focus mode</span>
|
||||||
|
<span className="font-mono opacity-60">
|
||||||
|
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ComposerActionButtonsProps = {
|
||||||
|
isMobile: boolean;
|
||||||
|
footerIconButtonClass: string;
|
||||||
|
sendIconSizeClass: string;
|
||||||
|
stopIconSizeClass: string;
|
||||||
|
canSend: boolean;
|
||||||
|
canAbort: boolean;
|
||||||
|
hasContent: boolean;
|
||||||
|
currentSessionId: string | null;
|
||||||
|
newSessionDraftOpen: boolean;
|
||||||
|
onPrimaryAction: () => void;
|
||||||
|
onQueueMessage: () => void;
|
||||||
|
onAbort: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) {
|
||||||
|
const {
|
||||||
|
isMobile,
|
||||||
|
footerIconButtonClass,
|
||||||
|
sendIconSizeClass,
|
||||||
|
stopIconSizeClass,
|
||||||
|
canSend,
|
||||||
|
canAbort,
|
||||||
|
hasContent,
|
||||||
|
currentSessionId,
|
||||||
|
newSessionDraftOpen,
|
||||||
|
onPrimaryAction,
|
||||||
|
onQueueMessage,
|
||||||
|
onAbort,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const sendButton = (
|
||||||
|
<button
|
||||||
|
type={isMobile ? 'button' : 'submit'}
|
||||||
|
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
onPrimaryAction();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
canSend && (currentSessionId || newSessionDraftOpen)
|
||||||
|
? 'text-primary hover:text-primary'
|
||||||
|
: 'opacity-30'
|
||||||
|
)}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canAbort) {
|
||||||
|
return sendButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{hasContent ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!currentSessionId}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (isMobile) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
onQueueMessage();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
|
||||||
|
currentSessionId ? 'text-primary hover:text-primary' : 'opacity-30'
|
||||||
|
)}
|
||||||
|
aria-label="Queue message"
|
||||||
|
>
|
||||||
|
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAbort}
|
||||||
|
className={cn(
|
||||||
|
footerIconButtonClass,
|
||||||
|
'text-[var(--status-error)] hover:text-[var(--status-error)]'
|
||||||
|
)}
|
||||||
|
aria-label="Stop generating"
|
||||||
|
>
|
||||||
|
<StopIcon className={cn(stopIconSizeClass)} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, (prev, next) => (
|
||||||
|
prev.isMobile === next.isMobile
|
||||||
|
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||||
|
&& prev.sendIconSizeClass === next.sendIconSizeClass
|
||||||
|
&& prev.stopIconSizeClass === next.stopIconSizeClass
|
||||||
|
&& prev.canSend === next.canSend
|
||||||
|
&& prev.canAbort === next.canAbort
|
||||||
|
&& prev.hasContent === next.hasContent
|
||||||
|
&& prev.currentSessionId === next.currentSessionId
|
||||||
|
&& prev.newSessionDraftOpen === next.newSessionDraftOpen
|
||||||
|
));
|
||||||
|
|
||||||
const appendWithLineBreaks = (base: string, next: string): string => {
|
const appendWithLineBreaks = (base: string, next: string): string => {
|
||||||
const separator = !base
|
const separator = !base
|
||||||
? ''
|
? ''
|
||||||
@@ -322,6 +686,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
||||||
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
||||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||||
|
const previousMessageLengthRef = React.useRef(message.length);
|
||||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||||
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
const suppressNextFileDropTextInsertRef = React.useRef(false);
|
||||||
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const suppressNextFileDropTextInsertTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -368,17 +733,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||||
|
|
||||||
const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore();
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
|
const setAgent = useConfigStore((state) => state.setAgent);
|
||||||
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
|
||||||
const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore();
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
|
||||||
|
const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen);
|
||||||
|
const cornerRadius = useUIStore((state) => state.cornerRadius);
|
||||||
|
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
||||||
|
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||||
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
||||||
|
const setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||||
const { working } = useAssistantStatus();
|
const { working } = useAssistantStatus();
|
||||||
const { git: runtimeGit } = useRuntimeAPIs();
|
const { git: runtimeGit } = useRuntimeAPIs();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
const chatSearchDirectory = useChatSearchDirectory();
|
const chatSearchDirectory = useChatSearchDirectory();
|
||||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||||
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
|
|
||||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
||||||
|
const composerHighlightRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||||
const chatInputRadius = 'var(--radius-lg)';
|
const chatInputRadius = 'var(--radius-lg)';
|
||||||
@@ -867,7 +1244,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [pendingInputText, consumePendingInputText]);
|
}, [pendingInputText, consumePendingInputText]);
|
||||||
|
|
||||||
const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
|
const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts;
|
||||||
const hasQueuedMessages = queuedMessages.length > 0;
|
const hasQueuedMessages = queuedMessages.length > 0;
|
||||||
const canSend = hasContent || hasQueuedMessages;
|
const canSend = hasContent || hasQueuedMessages;
|
||||||
|
|
||||||
@@ -913,6 +1290,29 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]);
|
||||||
|
|
||||||
|
const handleQueuedMessageEdit = React.useCallback((content: string) => {
|
||||||
|
setMessage(content);
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenAgentPanel = React.useCallback(() => {
|
||||||
|
setMobileControlsPanel('agent');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleExpandedInput = React.useCallback(() => {
|
||||||
|
setExpandedInput(!isExpandedInput);
|
||||||
|
}, [isExpandedInput, setExpandedInput]);
|
||||||
|
|
||||||
|
const openIssuePicker = React.useCallback(() => {
|
||||||
|
setIssuePickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openPrPicker = React.useCallback(() => {
|
||||||
|
setPrPickerOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (options?: SubmitOptions) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
const queuedOnly = options?.queuedOnly ?? false;
|
||||||
|
|
||||||
@@ -1509,20 +1909,27 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}
|
}
|
||||||
}, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
|
}, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]);
|
||||||
|
|
||||||
const adjustTextareaHeight = React.useCallback(() => {
|
const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousScrollTop = textarea.scrollTop;
|
||||||
|
|
||||||
if (isDesktopExpanded) {
|
if (isDesktopExpanded) {
|
||||||
textarea.style.height = '100%';
|
textarea.style.height = '100%';
|
||||||
textarea.style.maxHeight = 'none';
|
textarea.style.maxHeight = 'none';
|
||||||
setTextareaSize(null);
|
setTextareaSize(null);
|
||||||
|
if (textarea.scrollTop !== previousScrollTop) {
|
||||||
|
textarea.scrollTop = previousScrollTop;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.style.height = 'auto';
|
if (options?.allowShrink ?? true) {
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
const view = textarea.ownerDocument?.defaultView;
|
const view = textarea.ownerDocument?.defaultView;
|
||||||
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
const computedStyle = view ? view.getComputedStyle(textarea) : null;
|
||||||
@@ -1541,6 +1948,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
|
|
||||||
textarea.style.height = `${nextHeight}px`;
|
textarea.style.height = `${nextHeight}px`;
|
||||||
textarea.style.maxHeight = `${maxHeight}px`;
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
|
if (textarea.scrollTop !== previousScrollTop) {
|
||||||
|
textarea.scrollTop = previousScrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
setTextareaSize((prev) => {
|
setTextareaSize((prev) => {
|
||||||
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) {
|
||||||
@@ -1551,7 +1961,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
}, [isDesktopExpanded]);
|
}, [isDesktopExpanded]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
adjustTextareaHeight();
|
const allowShrink = message.length < previousMessageLengthRef.current;
|
||||||
|
previousMessageLengthRef.current = message.length;
|
||||||
|
adjustTextareaHeight({ allowShrink });
|
||||||
}, [adjustTextareaHeight, message, isMobile]);
|
}, [adjustTextareaHeight, message, isMobile]);
|
||||||
|
|
||||||
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => {
|
||||||
@@ -2723,238 +3135,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
});
|
});
|
||||||
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
}, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]);
|
||||||
|
|
||||||
const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled
|
|
||||||
? 'Disable permission auto-accept'
|
|
||||||
: 'Enable permission auto-accept';
|
|
||||||
const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled
|
|
||||||
? 'Permission auto-accept: on'
|
|
||||||
: 'Permission auto-accept: off';
|
|
||||||
|
|
||||||
const permissionAutoAcceptButton = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePermissionAutoAcceptToggle}
|
|
||||||
className={cn(
|
|
||||||
footerIconButtonClass,
|
|
||||||
'rounded-md hover:bg-transparent',
|
|
||||||
!permissionScopeSessionId && 'opacity-30',
|
|
||||||
)}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
}}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
if (event.pointerType === 'touch') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-pressed={permissionAutoAcceptEnabled}
|
|
||||||
aria-label={permissionAutoAcceptAriaLabel}
|
|
||||||
title={permissionAutoAcceptAriaLabel}
|
|
||||||
>
|
|
||||||
{permissionAutoAcceptEnabled ? (
|
|
||||||
<RiShieldCheckLine className={cn(iconSizeClass)} style={{ color: 'var(--status-info)' }} />
|
|
||||||
) : (
|
|
||||||
<RiShieldUserLine className={cn(iconSizeClass)} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const permissionAutoAcceptButtonWithTooltip = (
|
|
||||||
<Tooltip delayDuration={600}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
{permissionAutoAcceptButton}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" sideOffset={8}>
|
|
||||||
{permissionAutoAcceptTooltipLabel}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send button - respects queue mode setting
|
|
||||||
const sendButton = (
|
|
||||||
<button
|
|
||||||
type={isMobile ? 'button' : 'submit'}
|
|
||||||
disabled={!canSend || (!currentSessionId && !newSessionDraftOpen)}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!isMobile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
handlePrimaryAction();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
footerIconButtonClass,
|
|
||||||
canSend && (currentSessionId || newSessionDraftOpen)
|
|
||||||
? 'text-primary hover:text-primary'
|
|
||||||
: 'opacity-30'
|
|
||||||
)}
|
|
||||||
aria-label="Send message"
|
|
||||||
>
|
|
||||||
<RiSendPlane2Line className={cn(sendIconSizeClass)} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Queue button for adding message to queue while working
|
|
||||||
const queueButton = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!hasContent || !currentSessionId}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (isMobile) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
handleQueueMessage();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
footerIconButtonClass,
|
|
||||||
'absolute z-20 bottom-full left-1/2 -translate-x-1/2 mb-1',
|
|
||||||
hasContent && currentSessionId
|
|
||||||
? 'text-primary hover:text-primary'
|
|
||||||
: 'opacity-30'
|
|
||||||
)}
|
|
||||||
aria-label="Queue message"
|
|
||||||
>
|
|
||||||
<RiSendPlane2Line className={cn(sendIconSizeClass, '-rotate-90')} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop button replaces send button when working
|
|
||||||
const stopButton = (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAbort}
|
|
||||||
className={cn(
|
|
||||||
footerIconButtonClass,
|
|
||||||
'text-[var(--status-error)] hover:text-[var(--status-error)]'
|
|
||||||
)}
|
|
||||||
aria-label="Stop generating"
|
|
||||||
>
|
|
||||||
<StopIcon className={cn(stopIconSizeClass)} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Action buttons area: either send button, or stop (+ optional queue button floating above)
|
|
||||||
const actionButtons = canAbort ? (
|
|
||||||
<div className="relative">
|
|
||||||
{hasContent && queueButton}
|
|
||||||
{stopButton}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
sendButton
|
|
||||||
);
|
|
||||||
|
|
||||||
const attachmentMenu = (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleLocalFileSelect}
|
|
||||||
accept="*/*"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative inline-flex">
|
|
||||||
{isVSCode ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={footerIconButtonClass}
|
|
||||||
onClick={() => handlePickLocalFiles()}
|
|
||||||
title="Attach files"
|
|
||||||
aria-label="Attach files"
|
|
||||||
>
|
|
||||||
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={footerIconButtonClass}
|
|
||||||
title="Add attachment"
|
|
||||||
aria-label="Add attachment"
|
|
||||||
>
|
|
||||||
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
requestAnimationFrame(() => handlePickLocalFiles());
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiAttachment2 />
|
|
||||||
Attach files
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setIssuePickerOpen(true);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiGithubLine />
|
|
||||||
Link GitHub Issue
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setPrPickerOpen(true);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiGitPullRequestLine />
|
|
||||||
Link GitHub PR
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const settingsButton = onOpenSettings ? (
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
className={footerIconButtonClass}
|
|
||||||
title='Model and agent settings'
|
|
||||||
aria-label='Model and agent settings'
|
|
||||||
>
|
|
||||||
<RiAiAgentLine className={cn(iconSizeClass, 'text-current')} />
|
|
||||||
</button>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const attachmentsControls = (
|
|
||||||
<div className="flex items-center gap-x-1.5">
|
|
||||||
{isMobile ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
footerIconButtonClass,
|
|
||||||
'rounded-md',
|
|
||||||
'hover:bg-interactive-hover/40'
|
|
||||||
)}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
if (event.pointerType === 'touch') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={handleOpenCommandMenu}
|
|
||||||
title="Commands"
|
|
||||||
aria-label="Commands"
|
|
||||||
>
|
|
||||||
<RiCommandLine className={cn(iconSizeClass)} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{attachmentMenu}
|
|
||||||
{settingsButton}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const workingStatusText = working.statusText;
|
const workingStatusText = working.statusText;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -2998,12 +3178,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||||
<AttachedFilesList />
|
<AttachedFilesList />
|
||||||
<QueuedMessageChips
|
<QueuedMessageChips
|
||||||
onEditMessage={(content) => {
|
onEditMessage={handleQueuedMessageEdit}
|
||||||
setMessage(content);
|
|
||||||
setTimeout(() => {
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{hasDrafts && (
|
{hasDrafts && (
|
||||||
<div className="pb-2">
|
<div className="pb-2">
|
||||||
@@ -3122,7 +3297,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<StatusRow
|
<MemoStatusRow
|
||||||
isWorking={working.isWorking}
|
isWorking={working.isWorking}
|
||||||
statusText={workingStatusText}
|
statusText={workingStatusText}
|
||||||
isGenericStatus={working.isGenericStatus}
|
isGenericStatus={working.isGenericStatus}
|
||||||
@@ -3326,7 +3501,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
: 'pt-4 pb-2',
|
: 'pt-4 pb-2',
|
||||||
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
||||||
)}
|
)}
|
||||||
style={{ transform: `translateY(-${textareaScrollTop}px)` }}
|
ref={composerHighlightRef}
|
||||||
>
|
>
|
||||||
{highlightedComposerContent.map((part, index) => (
|
{highlightedComposerContent.map((part, index) => (
|
||||||
<span
|
<span
|
||||||
@@ -3361,7 +3536,10 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
onClick={updateAutocompleteOverlayPosition}
|
onClick={updateAutocompleteOverlayPosition}
|
||||||
onScroll={(event) => {
|
onScroll={(event) => {
|
||||||
updateAutocompleteOverlayPosition();
|
updateAutocompleteOverlayPosition();
|
||||||
setTextareaScrollTop(event.currentTarget.scrollTop);
|
const scrollTop = event.currentTarget.scrollTop;
|
||||||
|
if (composerHighlightRef.current) {
|
||||||
|
composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`;
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onSelect={updateAutocompleteOverlayPosition}
|
onSelect={updateAutocompleteOverlayPosition}
|
||||||
placeholder={currentSessionId || newSessionDraftOpen
|
placeholder={currentSessionId || newSessionDraftOpen
|
||||||
@@ -3411,32 +3589,63 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<>
|
<>
|
||||||
<div className="flex w-full items-center justify-between gap-x-1.5">
|
<div className="flex w-full items-center justify-between gap-x-1.5">
|
||||||
<div className="flex items-center gap-x-1.5">
|
<div className="flex items-center gap-x-1.5">
|
||||||
{attachmentsControls}
|
<ComposerAttachmentControls
|
||||||
{permissionAutoAcceptButton}
|
isMobile={isMobile}
|
||||||
|
isVSCode={isVSCode}
|
||||||
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
|
iconSizeClass={iconSizeClass}
|
||||||
|
fileInputRef={fileInputRef}
|
||||||
|
handleLocalFileSelect={handleLocalFileSelect}
|
||||||
|
handlePickLocalFiles={handlePickLocalFiles}
|
||||||
|
handleOpenCommandMenu={handleOpenCommandMenu}
|
||||||
|
openIssuePicker={openIssuePicker}
|
||||||
|
openPrPicker={openPrPicker}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
<PermissionAutoAcceptButton
|
||||||
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
|
iconSizeClass={iconSizeClass}
|
||||||
|
permissionScopeSessionId={permissionScopeSessionId}
|
||||||
|
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
||||||
|
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||||
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
<div className="flex items-center gap-x-1 min-w-0 max-w-[60vw] flex-shrink">
|
||||||
<MobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
|
<MemoMobileModelButton onOpenModel={handleOpenMobileControls} className="min-w-0 flex-shrink" />
|
||||||
<MobileAgentButton
|
<MemoMobileAgentButton
|
||||||
onOpenAgentPanel={() => setMobileControlsPanel('agent')}
|
onOpenAgentPanel={handleOpenAgentPanel}
|
||||||
onCycleAgent={handleCycleAgent}
|
onCycleAgent={handleCycleAgent}
|
||||||
className="min-w-0 flex-shrink"
|
className="min-w-0 flex-shrink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-1 flex-shrink-0">
|
<div className="flex items-center gap-x-1 flex-shrink-0">
|
||||||
<BrowserVoiceButton />
|
<MemoBrowserVoiceButton />
|
||||||
{actionButtons}
|
<ComposerActionButtons
|
||||||
|
isMobile={isMobile}
|
||||||
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
|
sendIconSizeClass={sendIconSizeClass}
|
||||||
|
stopIconSizeClass={stopIconSizeClass}
|
||||||
|
canSend={canSend}
|
||||||
|
canAbort={canAbort}
|
||||||
|
hasContent={!!hasContent}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
newSessionDraftOpen={newSessionDraftOpen}
|
||||||
|
onPrimaryAction={handlePrimaryAction}
|
||||||
|
onQueueMessage={handleQueueMessage}
|
||||||
|
onAbort={handleAbort}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ModelControls
|
<MemoModelControls
|
||||||
className="hidden"
|
className="hidden"
|
||||||
mobilePanel={mobileControlsPanel}
|
mobilePanel={mobileControlsPanel}
|
||||||
onMobilePanelChange={setMobileControlsPanel}
|
onMobilePanelChange={setMobileControlsPanel}
|
||||||
onMobilePanelSelection={handleReturnToUnifiedControls}
|
onMobilePanelSelection={handleReturnToUnifiedControls}
|
||||||
onAgentPanelSelection={() => setMobileControlsPanel(null)}
|
onAgentPanelSelection={() => setMobileControlsPanel(null)}
|
||||||
/>
|
/>
|
||||||
<UnifiedControlsDrawer
|
<MemoUnifiedControlsDrawer
|
||||||
open={mobileControlsOpen}
|
open={mobileControlsOpen}
|
||||||
onClose={handleCloseMobileControls}
|
onClose={handleCloseMobileControls}
|
||||||
onOpenModel={() => handleOpenMobilePanel('model')}
|
onOpenModel={() => handleOpenMobilePanel('model')}
|
||||||
@@ -3446,43 +3655,51 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||||
{attachmentsControls}
|
<ComposerAttachmentControls
|
||||||
<Tooltip delayDuration={600}>
|
isMobile={isMobile}
|
||||||
<TooltipTrigger asChild>
|
isVSCode={isVSCode}
|
||||||
<button
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
type="button"
|
iconSizeClass={iconSizeClass}
|
||||||
className={cn(
|
fileInputRef={fileInputRef}
|
||||||
footerIconButtonClass,
|
handleLocalFileSelect={handleLocalFileSelect}
|
||||||
'rounded-md',
|
handlePickLocalFiles={handlePickLocalFiles}
|
||||||
isExpandedInput
|
handleOpenCommandMenu={handleOpenCommandMenu}
|
||||||
? 'text-primary'
|
openIssuePicker={openIssuePicker}
|
||||||
: 'text-foreground hover:bg-[var(--interactive-hover)]/40'
|
openPrPicker={openPrPicker}
|
||||||
)}
|
onOpenSettings={onOpenSettings}
|
||||||
onMouseDown={(event) => {
|
/>
|
||||||
event.preventDefault();
|
<FocusModeButton
|
||||||
}}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
onClick={() => setExpandedInput(!isExpandedInput)}
|
iconSizeClass={iconSizeClass}
|
||||||
aria-label="Toggle focus mode"
|
isExpandedInput={isExpandedInput}
|
||||||
aria-pressed={isExpandedInput}
|
onToggle={handleToggleExpandedInput}
|
||||||
>
|
/>
|
||||||
<RiFullscreenLine className={cn(iconSizeClass)} />
|
<PermissionAutoAcceptButton
|
||||||
</button>
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
</TooltipTrigger>
|
iconSizeClass={iconSizeClass}
|
||||||
<TooltipContent side="top" sideOffset={8}>
|
permissionScopeSessionId={permissionScopeSessionId}
|
||||||
<div className="flex flex-col gap-0.5 text-center">
|
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
||||||
<span>Focus mode</span>
|
handlePermissionAutoAcceptToggle={handlePermissionAutoAcceptToggle}
|
||||||
<span className="font-mono opacity-60">
|
withTooltip
|
||||||
{isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{permissionAutoAcceptButtonWithTooltip}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
||||||
<ModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
|
||||||
<BrowserVoiceButton />
|
<MemoBrowserVoiceButton />
|
||||||
{actionButtons}
|
<ComposerActionButtons
|
||||||
|
isMobile={isMobile}
|
||||||
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
|
sendIconSizeClass={sendIconSizeClass}
|
||||||
|
stopIconSizeClass={stopIconSizeClass}
|
||||||
|
canSend={canSend}
|
||||||
|
canAbort={canAbort}
|
||||||
|
hasContent={!!hasContent}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
newSessionDraftOpen={newSessionDraftOpen}
|
||||||
|
onPrimaryAction={handlePrimaryAction}
|
||||||
|
onQueueMessage={handleQueueMessage}
|
||||||
|
onAbort={handleAbort}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import type { ToolPopupContent } from './message/types';
|
|||||||
|
|
||||||
export const FileAttachmentButton = memo(() => {
|
export const FileAttachmentButton = memo(() => {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { addAttachedFile } = useInputStore();
|
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||||
const { isMobile } = useUIStore();
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const isVSCodeRuntime = useIsVSCodeRuntime();
|
const isVSCodeRuntime = useIsVSCodeRuntime();
|
||||||
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7';
|
||||||
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]';
|
||||||
@@ -256,7 +256,8 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => {
|
|||||||
FileChip.displayName = 'FileChip';
|
FileChip.displayName = 'FileChip';
|
||||||
|
|
||||||
export const AttachedFilesList = memo(() => {
|
export const AttachedFilesList = memo(() => {
|
||||||
const { attachedFiles, removeAttachedFile } = useInputStore();
|
const attachedFiles = useInputStore((state) => state.attachedFiles);
|
||||||
|
const removeAttachedFile = useInputStore((state) => state.removeAttachedFile);
|
||||||
|
|
||||||
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
[projectRoot],
|
[projectRoot],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const { getVisibleAgents } = useConfigStore();
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||||
const showHidden = useDirectoryShowHidden();
|
const showHidden = useDirectoryShowHidden();
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const LONG_PRESS_MS = 500;
|
|||||||
|
|
||||||
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
||||||
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
||||||
const { currentAgentName, getVisibleAgents } = useConfigStore();
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionAgentName = useSelectionStore((state) =>
|
const sessionAgentName = useSelectionStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ interface MobileModelButtonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
|
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
|
||||||
const { currentModelId, getCurrentProvider } = useConfigStore();
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
|
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||||
const currentProvider = getCurrentProvider();
|
const currentProvider = getCurrentProvider();
|
||||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
|
const modelLabel = getModelDisplayName(currentProvider, currentModelId);
|
||||||
|
|
||||||
|
|||||||
@@ -1438,8 +1438,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
const agents = useConfigStore((state) => state.agents);
|
const agents = useConfigStore((state) => state.agents);
|
||||||
const { getCurrentModel } = useConfigStore();
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||||
const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore();
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||||
|
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
|
||||||
|
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
|
|
||||||
// Project store
|
// Project store
|
||||||
|
|||||||
@@ -293,25 +293,23 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
onMobilePanelSelection,
|
onMobilePanelSelection,
|
||||||
onAgentPanelSelection,
|
onAgentPanelSelection,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const providers = useConfigStore((state) => state.providers);
|
||||||
providers,
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
currentProviderId,
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
currentModelId,
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
currentVariant,
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
currentAgentName,
|
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||||
settingsDefaultVariant,
|
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||||
settingsDefaultAgent,
|
const setProvider = useConfigStore((state) => state.setProvider);
|
||||||
setProvider,
|
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
|
||||||
setSelectedProvider,
|
const setModel = useConfigStore((state) => state.setModel);
|
||||||
setModel,
|
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||||
setCurrentVariant,
|
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||||
getCurrentModelVariants,
|
const setAgent = useConfigStore((state) => state.setAgent);
|
||||||
setAgent,
|
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||||
getCurrentProvider,
|
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||||
getModelMetadata,
|
const getCurrentAgent = useConfigStore((state) => state.getCurrentAgent);
|
||||||
getCurrentAgent,
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
getVisibleAgents,
|
|
||||||
} = useConfigStore();
|
|
||||||
|
|
||||||
// Use visible agents (excludes hidden internal agents)
|
// Use visible agents (excludes hidden internal agents)
|
||||||
const agents = getVisibleAgents();
|
const agents = getVisibleAgents();
|
||||||
@@ -321,15 +319,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
|
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
|
|
||||||
const {
|
const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection);
|
||||||
getSessionModelSelection,
|
const saveSessionModelSelection = useSelectionStore((state) => state.saveSessionModelSelection);
|
||||||
saveSessionModelSelection,
|
const saveSessionAgentSelection = useSelectionStore((state) => state.saveSessionAgentSelection);
|
||||||
saveSessionAgentSelection,
|
const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession);
|
||||||
saveAgentModelForSession,
|
const getAgentModelForSession = useSelectionStore((state) => state.getAgentModelForSession);
|
||||||
getAgentModelForSession,
|
const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession);
|
||||||
saveAgentModelVariantForSession,
|
const getAgentModelVariantForSession = useSelectionStore((state) => state.getAgentModelVariantForSession);
|
||||||
getAgentModelVariantForSession,
|
|
||||||
} = useSelectionStore();
|
|
||||||
|
|
||||||
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
const contextHydrated = useContextStore((state) => state.hasHydrated);
|
||||||
|
|
||||||
@@ -355,19 +351,17 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
|
? (sessionSavedAgentName || stickySessionAgentName || currentAgentName)
|
||||||
: currentAgentName;
|
: currentAgentName;
|
||||||
|
|
||||||
const {
|
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
|
||||||
toggleFavoriteModel,
|
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
|
||||||
isFavoriteModel,
|
const collapsedModelProviders = useUIStore((state) => state.collapsedModelProviders);
|
||||||
collapsedModelProviders,
|
const toggleModelProviderCollapsed = useUIStore((state) => state.toggleModelProviderCollapsed);
|
||||||
toggleModelProviderCollapsed,
|
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
||||||
addRecentModel,
|
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
|
||||||
addRecentAgent,
|
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
|
||||||
addRecentEffort,
|
const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen);
|
||||||
isModelSelectorOpen,
|
const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
|
||||||
setModelSelectorOpen,
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
setSettingsDialogOpen,
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
||||||
setSettingsPage,
|
|
||||||
} = useUIStore();
|
|
||||||
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
||||||
const collapsedProviderSet = React.useMemo(
|
const collapsedProviderSet = React.useMemo(
|
||||||
() => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
|
() => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)),
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ interface StatusChipProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
export const StatusChip: React.FC<StatusChipProps> = ({ onClick, className }) => {
|
||||||
const {
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
currentModelId,
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
currentVariant,
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
currentAgentName,
|
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
||||||
getCurrentProvider,
|
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||||
getCurrentModelVariants,
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
getVisibleAgents,
|
|
||||||
} = useConfigStore();
|
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionAgentName = useContextStore((state) =>
|
const sessionAgentName = useContextStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
|
() => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS),
|
||||||
[todosRecord, currentSessionId],
|
[todosRecord, currentSessionId],
|
||||||
);
|
);
|
||||||
const { isMobile } = useUIStore();
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const isCompact = isMobile || isVSCodeRuntime();
|
const isCompact = isMobile || isVSCodeRuntime();
|
||||||
|
|
||||||
// Filter out cancelled todos for display and keep original order.
|
// Filter out cancelled todos for display and keep original order.
|
||||||
|
|||||||
@@ -43,21 +43,22 @@ export const UnifiedControlsDrawer: React.FC<UnifiedControlsDrawerProps> = ({
|
|||||||
onOpenModel,
|
onOpenModel,
|
||||||
onOpenEffort,
|
onOpenEffort,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const providers = useConfigStore((state) => state.providers);
|
||||||
providers,
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
currentProviderId,
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
currentModelId,
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
currentVariant,
|
const setProvider = useConfigStore((state) => state.setProvider);
|
||||||
setProvider,
|
const setModel = useConfigStore((state) => state.setModel);
|
||||||
setModel,
|
const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant);
|
||||||
setCurrentVariant,
|
const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants);
|
||||||
getCurrentModelVariants,
|
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||||
getModelMetadata,
|
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
||||||
} = useConfigStore();
|
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
|
||||||
const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore();
|
const recentEfforts = useUIStore((state) => state.recentEfforts);
|
||||||
const { recentModelsList } = useModelLists();
|
const { recentModelsList } = useModelLists();
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore();
|
const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession);
|
||||||
|
const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession);
|
||||||
const sessionAgentName = useContextStore((state) =>
|
const sessionAgentName = useContextStore((state) =>
|
||||||
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -51,26 +51,70 @@ const normalizeDirectoryKey = (value: string): string => {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MemoSessionSidebar = React.memo(SessionSidebar);
|
||||||
|
const MemoHeader = React.memo(Header);
|
||||||
|
const MemoChatView = React.memo(ChatView);
|
||||||
|
const MemoPlanView = React.memo(PlanView);
|
||||||
|
const MemoGitView = React.memo(GitView);
|
||||||
|
const MemoDiffView = React.memo(DiffView);
|
||||||
|
const MemoTerminalView = React.memo(TerminalView);
|
||||||
|
const MemoFilesView = React.memo(FilesView);
|
||||||
|
const MemoRightSidebarTabs = React.memo(RightSidebarTabs);
|
||||||
|
|
||||||
|
const DesktopLeftSidebar = React.memo(function DesktopLeftSidebar({
|
||||||
|
isSidebarOpen,
|
||||||
|
isMobile,
|
||||||
|
}: {
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Sidebar isOpen={isSidebarOpen} isMobile={isMobile} className="border-0">
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MemoSessionSidebar />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const DesktopRightPanel = React.memo(function DesktopRightPanel({
|
||||||
|
isRightSidebarOpen,
|
||||||
|
setDesktopRightSidebarActionsHost,
|
||||||
|
}: {
|
||||||
|
isRightSidebarOpen: boolean;
|
||||||
|
setDesktopRightSidebarActionsHost: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<RightSidebar
|
||||||
|
isOpen={isRightSidebarOpen}
|
||||||
|
className="border-0"
|
||||||
|
onTopActionsHostChange={setDesktopRightSidebarActionsHost}
|
||||||
|
>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<MemoRightSidebarTabs />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</RightSidebar>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export const MainLayout: React.FC = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
|
||||||
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
|
||||||
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
|
const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640;
|
||||||
const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700;
|
const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700;
|
||||||
const {
|
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||||
isSidebarOpen,
|
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
|
||||||
isRightSidebarOpen,
|
const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen);
|
||||||
isBottomTerminalOpen,
|
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
|
||||||
setRightSidebarOpen,
|
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
|
||||||
setBottomTerminalOpen,
|
const activeMainTab = useUIStore((state) => state.activeMainTab);
|
||||||
activeMainTab,
|
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||||
setIsMobile,
|
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||||
isSessionSwitcherOpen,
|
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||||
isSettingsDialogOpen,
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
setSettingsDialogOpen,
|
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||||
isMultiRunLauncherOpen,
|
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
||||||
setMultiRunLauncherOpen,
|
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
||||||
multiRunLauncherPrefillPrompt,
|
|
||||||
} = useUIStore();
|
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
|
||||||
const { sidebarWidth, setSidebarWidth } = useUIStore();
|
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
|
||||||
|
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
|
||||||
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
const isDesktopApp = React.useMemo(() => isDesktopShell(), []);
|
||||||
const [isResizing, setIsResizing] = React.useState(false);
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
const startXRef = React.useRef(0);
|
const startXRef = React.useRef(0);
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
|||||||
maxModels,
|
maxModels,
|
||||||
addButtonClassName,
|
addButtonClassName,
|
||||||
}) => {
|
}) => {
|
||||||
const { providers, modelsMetadata } = useConfigStore();
|
const providers = useConfigStore((state) => state.providers);
|
||||||
|
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
const [isOpen, setIsOpen] = React.useState(false);
|
||||||
const [searchQuery, setSearchQuery] = React.useState('');
|
const [searchQuery, setSearchQuery] = React.useState('');
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ interface ThemeProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const { fontSize, applyTypography, padding, applyPadding } = useUIStore();
|
const fontSize = useUIStore((state) => state.fontSize);
|
||||||
|
const applyTypography = useUIStore((state) => state.applyTypography);
|
||||||
|
const padding = useUIStore((state) => state.padding);
|
||||||
|
const applyPadding = useUIStore((state) => state.applyPadding);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
applyTypography();
|
applyTypography();
|
||||||
|
|||||||
@@ -55,10 +55,13 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||||||
allowedProviderIds,
|
allowedProviderIds,
|
||||||
placeholder
|
placeholder
|
||||||
}) => {
|
}) => {
|
||||||
const { providers, modelsMetadata } = useConfigStore();
|
const providers = useConfigStore((state) => state.providers);
|
||||||
|
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||||
const isMobile = useUIStore(state => state.isMobile);
|
const isMobile = useUIStore(state => state.isMobile);
|
||||||
const hiddenModels = useUIStore(state => state.hiddenModels);
|
const hiddenModels = useUIStore(state => state.hiddenModels);
|
||||||
const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore();
|
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
|
||||||
|
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
|
||||||
|
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
||||||
const { favoriteModelsList, recentModelsList } = useModelLists();
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
||||||
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
const { isMobile: deviceIsMobile } = useDeviceInfo();
|
||||||
const isActuallyMobile = isMobile || deviceIsMobile;
|
const isActuallyMobile = isMobile || deviceIsMobile;
|
||||||
|
|||||||
@@ -45,12 +45,10 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||||
const {
|
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||||
shortcutOverrides,
|
const setShortcutOverride = useUIStore((state) => state.setShortcutOverride);
|
||||||
setShortcutOverride,
|
const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride);
|
||||||
clearShortcutOverride,
|
const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides);
|
||||||
resetAllShortcutOverrides,
|
|
||||||
} = useUIStore();
|
|
||||||
|
|
||||||
const actions = React.useMemo(() => getCustomizableShortcutActions(), []);
|
const actions = React.useMemo(() => getCustomizableShortcutActions(), []);
|
||||||
|
|
||||||
|
|||||||
@@ -54,36 +54,34 @@ export const VoiceSettings: React.FC = () => {
|
|||||||
language,
|
language,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
} = useBrowserVoice();
|
} = useBrowserVoice();
|
||||||
const {
|
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||||
voiceProvider,
|
const setVoiceProvider = useConfigStore((state) => state.setVoiceProvider);
|
||||||
setVoiceProvider,
|
const speechRate = useConfigStore((state) => state.speechRate);
|
||||||
speechRate,
|
const setSpeechRate = useConfigStore((state) => state.setSpeechRate);
|
||||||
setSpeechRate,
|
const speechPitch = useConfigStore((state) => state.speechPitch);
|
||||||
speechPitch,
|
const setSpeechPitch = useConfigStore((state) => state.setSpeechPitch);
|
||||||
setSpeechPitch,
|
const speechVolume = useConfigStore((state) => state.speechVolume);
|
||||||
speechVolume,
|
const setSpeechVolume = useConfigStore((state) => state.setSpeechVolume);
|
||||||
setSpeechVolume,
|
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||||
sayVoice,
|
const setSayVoice = useConfigStore((state) => state.setSayVoice);
|
||||||
setSayVoice,
|
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||||
browserVoice,
|
const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice);
|
||||||
setBrowserVoice,
|
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||||
openaiVoice,
|
const setOpenaiVoice = useConfigStore((state) => state.setOpenaiVoice);
|
||||||
setOpenaiVoice,
|
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
|
||||||
openaiApiKey,
|
const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey);
|
||||||
setOpenaiApiKey,
|
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||||
showMessageTTSButtons,
|
const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons);
|
||||||
setShowMessageTTSButtons,
|
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||||
voiceModeEnabled,
|
const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled);
|
||||||
setVoiceModeEnabled,
|
const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS);
|
||||||
summarizeMessageTTS,
|
const setSummarizeMessageTTS = useConfigStore((state) => state.setSummarizeMessageTTS);
|
||||||
setSummarizeMessageTTS,
|
const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation);
|
||||||
summarizeVoiceConversation,
|
const setSummarizeVoiceConversation = useConfigStore((state) => state.setSummarizeVoiceConversation);
|
||||||
setSummarizeVoiceConversation,
|
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||||
summarizeCharacterThreshold,
|
const setSummarizeCharacterThreshold = useConfigStore((state) => state.setSummarizeCharacterThreshold);
|
||||||
setSummarizeCharacterThreshold,
|
const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength);
|
||||||
summarizeMaxLength,
|
const setSummarizeMaxLength = useConfigStore((state) => state.setSummarizeMaxLength);
|
||||||
setSummarizeMaxLength,
|
|
||||||
} = useConfigStore();
|
|
||||||
|
|
||||||
const [isSayAvailable, setIsSayAvailable] = useState(false);
|
const [isSayAvailable, setIsSayAvailable] = useState(false);
|
||||||
const [sayVoices, setSayVoices] = useState<Array<{ name: string; locale: string }>>([]);
|
const [sayVoices, setSayVoices] = useState<Array<{ name: string; locale: string }>>([]);
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { formatDirectoryName, cn } from '@/lib/utils';
|
import { formatDirectoryName, cn } from '@/lib/utils';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useViewportStore } from '@/sync/viewport-store';
|
import { useSidebarSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
||||||
import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context';
|
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch';
|
||||||
@@ -57,6 +56,7 @@ import {
|
|||||||
} from './sidebar/ConfirmDialogs';
|
} from './sidebar/ConfirmDialogs';
|
||||||
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
import { type SessionGroup, type SessionNode } from './sidebar/types';
|
||||||
import {
|
import {
|
||||||
|
type ActiveNowEntry,
|
||||||
addActiveNowSession,
|
addActiveNowSession,
|
||||||
deriveActiveNowSessions,
|
deriveActiveNowSessions,
|
||||||
persistActiveNowEntries,
|
persistActiveNowEntries,
|
||||||
@@ -68,7 +68,7 @@ import {
|
|||||||
formatProjectLabel,
|
formatProjectLabel,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
} from './sidebar/utils';
|
} from './sidebar/utils';
|
||||||
import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
|
|
||||||
@@ -113,6 +113,50 @@ interface SessionSidebarProps {
|
|||||||
showOnlyMainWorkspace?: boolean;
|
showOnlyMainWorkspace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionStatusActivityBridgeProps = {
|
||||||
|
safeStorage: Storage;
|
||||||
|
setActiveNowEntries: React.Dispatch<React.SetStateAction<ActiveNowEntry[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionStatusActivityBridge: React.FC<SessionStatusActivityBridgeProps> = ({
|
||||||
|
safeStorage,
|
||||||
|
setActiveNowEntries,
|
||||||
|
}) => {
|
||||||
|
const globalSessionStatuses = useAllSessionStatuses();
|
||||||
|
const sessionStatus = React.useMemo(
|
||||||
|
() => new Map(Object.entries(globalSessionStatuses)),
|
||||||
|
[globalSessionStatuses],
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const nextStreamingIds = new Set<string>();
|
||||||
|
sessionStatus.forEach((status, sessionId) => {
|
||||||
|
if (status?.type === 'busy' || status?.type === 'retry') {
|
||||||
|
nextStreamingIds.add(sessionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousStreamingIds = previousStreamingIdsRef.current;
|
||||||
|
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
|
||||||
|
if (startedStreamingIds.length > 0) {
|
||||||
|
setActiveNowEntries((prev) => {
|
||||||
|
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
||||||
|
if (next === prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
persistActiveNowEntries(safeStorage, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
previousStreamingIdsRef.current = nextStreamingIds;
|
||||||
|
}, [sessionStatus, safeStorage, setActiveNowEntries]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||||
mobileVariant = false,
|
mobileVariant = false,
|
||||||
onSessionSelected,
|
onSessionSelected,
|
||||||
@@ -137,7 +181,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
|
|
||||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
|
||||||
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false);
|
||||||
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
||||||
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false);
|
||||||
@@ -267,10 +310,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const gitBranches = useGitAllBranches();
|
const gitBranches = useGitAllBranches();
|
||||||
|
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
const syncSessions = useSessions();
|
const syncSessions = useSidebarSessions();
|
||||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||||
const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory);
|
|
||||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||||
@@ -278,37 +320,54 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||||
const shareSession = useSessionUIStore((state) => state.shareSession);
|
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||||
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
||||||
const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState);
|
|
||||||
const globalSessionStatuses = useAllSessionStatuses();
|
|
||||||
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
|
// sessionAttentionStates removed — now using notification-store directly in SessionNodeItem
|
||||||
const permissionsRecord = useDirectorySync((state) => state.permission);
|
|
||||||
|
|
||||||
const sessionStatus = React.useMemo(
|
|
||||||
() => new Map(Object.entries(globalSessionStatuses)),
|
|
||||||
[globalSessionStatuses],
|
|
||||||
);
|
|
||||||
const permissions = React.useMemo(
|
|
||||||
() => new Map(Object.entries(permissionsRecord)),
|
|
||||||
[permissionsRecord],
|
|
||||||
);
|
|
||||||
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata);
|
||||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory);
|
|
||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const updateStore = useUpdateStore();
|
const updateStore = useUpdateStore();
|
||||||
|
|
||||||
const sessions = React.useMemo(
|
const sessions = React.useMemo(() => {
|
||||||
() => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions),
|
if (!hasLoadedGlobalSessions) {
|
||||||
[globalActiveSessions, hasLoadedGlobalSessions, syncSessions],
|
return syncSessions;
|
||||||
);
|
}
|
||||||
|
|
||||||
const syncSessionSignature = React.useMemo(
|
if (syncSessions.length === 0) {
|
||||||
|
return globalActiveSessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncedById = new Map(syncSessions.map((session) => [session.id, session]));
|
||||||
|
const merged = globalActiveSessions.map((session) => syncedById.get(session.id) ?? session);
|
||||||
|
const seenIds = new Set(merged.map((session) => session.id));
|
||||||
|
|
||||||
|
syncSessions.forEach((session) => {
|
||||||
|
if (seenIds.has(session.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDirectory = resolveGlobalSessionDirectory(session);
|
||||||
|
if (sessionDirectory && sessionDirectory === currentDirectory) {
|
||||||
|
merged.push(session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]);
|
||||||
|
|
||||||
|
const syncSessionStructureSignature = React.useMemo(
|
||||||
() => syncSessions
|
() => syncSessions
|
||||||
.map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`)
|
.map((session) => {
|
||||||
|
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||||
|
return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`;
|
||||||
|
})
|
||||||
.join('|'),
|
.join('|'),
|
||||||
[syncSessions],
|
[syncSessions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const syncSessionsSnapshotRef = React.useRef<Session[]>(syncSessions);
|
||||||
|
React.useEffect(() => {
|
||||||
|
syncSessionsSnapshotRef.current = syncSessions;
|
||||||
|
}, [syncSessionStructureSignature, syncSessions]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -346,13 +405,13 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
void refreshGlobalSessions(syncSessions);
|
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||||
void discoverWorktrees();
|
void discoverWorktrees();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [currentDirectory, syncSessionSignature, syncSessions]);
|
}, [currentDirectory, syncSessionStructureSignature]);
|
||||||
|
|
||||||
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
|
||||||
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||||
@@ -489,6 +548,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds));
|
||||||
}, [sessions, pinnedSessionIds]);
|
}, [sessions, pinnedSessionIds]);
|
||||||
|
|
||||||
|
const sessionOrderIndex = React.useMemo(
|
||||||
|
() => new Map(sortedSessions.map((session, index) => [session.id, index])),
|
||||||
|
[sortedSessions],
|
||||||
|
);
|
||||||
|
|
||||||
const allKnownSessionsById = React.useMemo(() => {
|
const allKnownSessionsById = React.useMemo(() => {
|
||||||
const next = new Map<string, Session>();
|
const next = new Map<string, Session>();
|
||||||
[...sessions, ...archivedSessions].forEach((session) => {
|
[...sessions, ...archivedSessions].forEach((session) => {
|
||||||
@@ -506,77 +570,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
persistActiveNowEntries(safeStorage, pruned);
|
persistActiveNowEntries(safeStorage, pruned);
|
||||||
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
|
}, [activeNowEntries, allKnownSessionsById, safeStorage]);
|
||||||
|
|
||||||
const previousStreamingIdsRef = React.useRef<Set<string>>(new Set());
|
|
||||||
React.useEffect(() => {
|
|
||||||
const nextStreamingIds = new Set<string>();
|
|
||||||
sessionStatus?.forEach((status, sessionId) => {
|
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
|
||||||
nextStreamingIds.add(sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousStreamingIds = previousStreamingIdsRef.current;
|
|
||||||
const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId));
|
|
||||||
if (startedStreamingIds.length > 0) {
|
|
||||||
setActiveNowEntries((prev) => {
|
|
||||||
const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev);
|
|
||||||
if (next === prev) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
persistActiveNowEntries(safeStorage, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
previousStreamingIdsRef.current = nextStreamingIds;
|
|
||||||
}, [sessionStatus, safeStorage]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const busyIds: string[] = [];
|
|
||||||
sessionStatus?.forEach((status, sessionId) => {
|
|
||||||
if (status?.type === 'busy' || status?.type === 'retry') {
|
|
||||||
busyIds.push(sessionId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (busyIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveNowEntries((prev) => {
|
|
||||||
const known = new Set(prev.map((entry) => entry.sessionId));
|
|
||||||
let next = prev;
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
busyIds.forEach((sessionId) => {
|
|
||||||
if (known.has(sessionId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = allKnownSessionsById.get(sessionId);
|
|
||||||
if (!session || session.time?.archived) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID);
|
|
||||||
if (isSubtask) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next = addActiveNowSession(next, sessionId);
|
|
||||||
known.add(sessionId);
|
|
||||||
changed = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
persistActiveNowEntries(safeStorage, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [sessionStatus, allKnownSessionsById, safeStorage]);
|
|
||||||
|
|
||||||
const childrenMap = React.useMemo(() => {
|
const childrenMap = React.useMemo(() => {
|
||||||
const map = new Map<string, Session[]>();
|
const map = new Map<string, Session[]>();
|
||||||
sortedSessions.forEach((session) => {
|
sortedSessions.forEach((session) => {
|
||||||
@@ -887,8 +880,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
isVSCode,
|
isVSCode,
|
||||||
sessions,
|
sessions,
|
||||||
archivedSessions,
|
archivedSessions,
|
||||||
sessionsByDirectory,
|
|
||||||
getSessionsByDirectory,
|
|
||||||
availableWorktreesByProject,
|
availableWorktreesByProject,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1248,15 +1239,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
archivedBucket={archivedBucket}
|
archivedBucket={archivedBucket}
|
||||||
directoryStatus={directoryStatus}
|
directoryStatus={directoryStatus}
|
||||||
sessionMemoryState={sessionMemoryState as Map<string, { isZombie?: boolean }>}
|
|
||||||
currentSessionId={currentSessionId}
|
currentSessionId={currentSessionId}
|
||||||
pinnedSessionIds={pinnedSessionIds}
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
expandedParents={expandedParents}
|
expandedParents={expandedParents}
|
||||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
notifyOnSubtasks={notifyOnSubtasks}
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
sessionStatus={sessionStatus as Map<string, { type?: string }> | undefined}
|
|
||||||
permissions={permissions as Map<string, unknown[]>}
|
|
||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
setEditingId={setEditingId}
|
setEditingId={setEditingId}
|
||||||
editTitle={editTitle}
|
editTitle={editTitle}
|
||||||
@@ -1289,15 +1277,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
),
|
),
|
||||||
[
|
[
|
||||||
directoryStatus,
|
directoryStatus,
|
||||||
sessionMemoryState,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
expandedParents,
|
expandedParents,
|
||||||
hasSessionSearchQuery,
|
hasSessionSearchQuery,
|
||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
sessionStatus,
|
|
||||||
permissions,
|
|
||||||
editingId,
|
editingId,
|
||||||
setEditingId,
|
setEditingId,
|
||||||
editTitle,
|
editTitle,
|
||||||
@@ -1395,6 +1380,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
setRenameFolderDraft={setRenameFolderDraft}
|
setRenameFolderDraft={setRenameFolderDraft}
|
||||||
setRenamingFolderId={setRenamingFolderId}
|
setRenamingFolderId={setRenamingFolderId}
|
||||||
pinnedSessionIds={pinnedSessionIds}
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
sessionOrderIndex={sessionOrderIndex}
|
||||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||||
dragHandleProps={dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
@@ -1428,6 +1414,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
renamingFolderId,
|
renamingFolderId,
|
||||||
renameFolderDraft,
|
renameFolderDraft,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
|
sessionOrderIndex,
|
||||||
prVisualStateByDirectoryBranch,
|
prVisualStateByDirectoryBranch,
|
||||||
toggleCollapsedGroup,
|
toggleCollapsedGroup,
|
||||||
],
|
],
|
||||||
@@ -1490,6 +1477,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SessionStatusActivityBridge
|
||||||
|
safeStorage={safeStorage}
|
||||||
|
setActiveNowEntries={setActiveNowEntries}
|
||||||
|
/>
|
||||||
|
|
||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||||
@@ -1530,8 +1522,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
|||||||
collapsedProjects={collapsedProjects}
|
collapsedProjects={collapsedProjects}
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
projectRepoStatus={projectRepoStatus}
|
projectRepoStatus={projectRepoStatus}
|
||||||
hoveredProjectId={hoveredProjectId}
|
|
||||||
setHoveredProjectId={setHoveredProjectId}
|
|
||||||
isDesktopShellRuntime={isDesktopShellRuntime}
|
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||||
stuckProjectHeaders={stuckProjectHeaders}
|
stuckProjectHeaders={stuckProjectHeaders}
|
||||||
mobileVariant={mobileVariant}
|
mobileVariant={mobileVariant}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ type Props = {
|
|||||||
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
pinnedSessionIds: Set<string>;
|
pinnedSessionIds: Set<string>;
|
||||||
|
sessionOrderIndex: Map<string, number>;
|
||||||
prVisualStateByDirectoryBranch: Map<string, {
|
prVisualStateByDirectoryBranch: Map<string, {
|
||||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||||
number: number;
|
number: number;
|
||||||
@@ -130,12 +131,24 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
setRenameFolderDraft,
|
setRenameFolderDraft,
|
||||||
setRenamingFolderId,
|
setRenamingFolderId,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
|
sessionOrderIndex,
|
||||||
prVisualStateByDirectoryBranch,
|
prVisualStateByDirectoryBranch,
|
||||||
onToggleCollapsedGroup,
|
onToggleCollapsedGroup,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
compactBodyPadding = false,
|
compactBodyPadding = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
||||||
|
const aIndex = sessionOrderIndex.get(a.session.id);
|
||||||
|
const bIndex = sessionOrderIndex.get(b.session.id);
|
||||||
|
if (aIndex !== undefined || bIndex !== undefined) {
|
||||||
|
if (aIndex === undefined) return 1;
|
||||||
|
if (bIndex === undefined) return -1;
|
||||||
|
if (aIndex !== bIndex) return aIndex - bIndex;
|
||||||
|
}
|
||||||
|
return compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds);
|
||||||
|
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||||
|
|
||||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||||
const isMinimalMode = displayMode === 'minimal';
|
const isMinimalMode = displayMode === 'minimal';
|
||||||
@@ -144,7 +157,11 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||||
const shouldFilterGroupContents = hasSessionSearchQuery;
|
const shouldFilterGroupContents = hasSessionSearchQuery;
|
||||||
const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions;
|
const sourceGroupNodes = React.useMemo(
|
||||||
|
() => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)]
|
||||||
|
.sort(compareSessionNodes),
|
||||||
|
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||||
|
);
|
||||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||||
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
||||||
|
|
||||||
@@ -163,7 +180,7 @@ export function SessionGroupSection(props: Props): React.ReactNode {
|
|||||||
const nodes = folder.sessionIds
|
const nodes = folder.sessionIds
|
||||||
.map((sid) => nodeBySessionId.get(sid))
|
.map((sid) => nodeBySessionId.get(sid))
|
||||||
.filter((n): n is SessionNode => Boolean(n))
|
.filter((n): n is SessionNode => Boolean(n))
|
||||||
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
|
.sort(compareSessionNodes);
|
||||||
return { folder, nodes };
|
return { folder, nodes };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context';
|
||||||
|
import { useViewportStore } from '@/sync/viewport-store';
|
||||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||||
@@ -60,15 +62,12 @@ type Props = {
|
|||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
archivedBucket?: boolean;
|
archivedBucket?: boolean;
|
||||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||||||
sessionMemoryState: Map<string, { isZombie?: boolean }>;
|
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
pinnedSessionIds: Set<string>;
|
pinnedSessionIds: Set<string>;
|
||||||
expandedParents: Set<string>;
|
expandedParents: Set<string>;
|
||||||
hasSessionSearchQuery: boolean;
|
hasSessionSearchQuery: boolean;
|
||||||
normalizedSessionSearchQuery: string;
|
normalizedSessionSearchQuery: string;
|
||||||
notifyOnSubtasks: boolean;
|
notifyOnSubtasks: boolean;
|
||||||
sessionStatus?: Map<string, { type?: string }>;
|
|
||||||
permissions: Map<string, unknown[]>;
|
|
||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
setEditingId: (id: string | null) => void;
|
setEditingId: (id: string | null) => void;
|
||||||
editTitle: string;
|
editTitle: string;
|
||||||
@@ -99,7 +98,59 @@ type Props = {
|
|||||||
renderContext?: 'project' | 'recent';
|
renderContext?: 'project' | 'recent';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SessionNodeItem(props: Props): React.ReactNode {
|
const getNodeChildSignature = (node: SessionNode): string => {
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.children
|
||||||
|
.map((child) => `${child.session.id}:${child.children.length}`)
|
||||||
|
.join('|');
|
||||||
|
};
|
||||||
|
|
||||||
|
const areEqual = (prev: Props, next: Props): boolean => {
|
||||||
|
const prevSession = prev.node.session;
|
||||||
|
const nextSession = next.node.session;
|
||||||
|
const prevSessionId = prevSession.id;
|
||||||
|
const nextSessionId = nextSession.id;
|
||||||
|
|
||||||
|
if (prevSessionId !== nextSessionId) return false;
|
||||||
|
if (prev.node.session !== next.node.session) return false;
|
||||||
|
if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false;
|
||||||
|
if (prev.depth !== next.depth) return false;
|
||||||
|
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||||
|
if (prev.projectId !== next.projectId) return false;
|
||||||
|
if (prev.archivedBucket !== next.archivedBucket) return false;
|
||||||
|
if ((prev.currentSessionId === prevSessionId) !== (next.currentSessionId === nextSessionId)) return false;
|
||||||
|
if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false;
|
||||||
|
if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false;
|
||||||
|
if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false;
|
||||||
|
if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false;
|
||||||
|
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false;
|
||||||
|
if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false;
|
||||||
|
if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false;
|
||||||
|
if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false;
|
||||||
|
|
||||||
|
const prevMenuKey = `${prev.renderContext ?? 'project'}:${prev.archivedBucket ? 'archived' : 'active'}:${prevSessionId}`;
|
||||||
|
const nextMenuKey = `${next.renderContext ?? 'project'}:${next.archivedBucket ? 'archived' : 'active'}:${nextSessionId}`;
|
||||||
|
if ((prev.openSidebarMenuKey === prevMenuKey) !== (next.openSidebarMenuKey === nextMenuKey)) return false;
|
||||||
|
|
||||||
|
const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null)
|
||||||
|
?? normalizePath(prev.groupDirectory ?? null);
|
||||||
|
const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null)
|
||||||
|
?? normalizePath(next.groupDirectory ?? null);
|
||||||
|
if (prevDirectory !== nextDirectory) return false;
|
||||||
|
if ((prevDirectory ? prev.directoryStatus.get(prevDirectory) : null) !== (nextDirectory ? next.directoryStatus.get(nextDirectory) : null)) return false;
|
||||||
|
|
||||||
|
if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false;
|
||||||
|
if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false;
|
||||||
|
if (prev.mobileVariant !== next.mobileVariant) return false;
|
||||||
|
if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||||
const {
|
const {
|
||||||
node,
|
node,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
@@ -107,15 +158,12 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
projectId,
|
projectId,
|
||||||
archivedBucket = false,
|
archivedBucket = false,
|
||||||
directoryStatus,
|
directoryStatus,
|
||||||
sessionMemoryState,
|
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
expandedParents,
|
expandedParents,
|
||||||
hasSessionSearchQuery,
|
hasSessionSearchQuery,
|
||||||
normalizedSessionSearchQuery,
|
normalizedSessionSearchQuery,
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
sessionStatus,
|
|
||||||
permissions,
|
|
||||||
editingId,
|
editingId,
|
||||||
setEditingId,
|
setEditingId,
|
||||||
editTitle,
|
editTitle,
|
||||||
@@ -163,24 +211,30 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
const suppressNextSelectRef = React.useRef(false);
|
const suppressNextSelectRef = React.useRef(false);
|
||||||
|
|
||||||
const session = node.session;
|
const session = node.session;
|
||||||
|
const liveSession = useSession(session.id);
|
||||||
|
const resolvedSession = liveSession ?? session;
|
||||||
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`;
|
||||||
const sessionDirectory =
|
const sessionDirectory =
|
||||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||||
?? normalizePath(groupDirectory ?? null);
|
?? normalizePath(groupDirectory ?? null);
|
||||||
|
const isZombie = useViewportStore(
|
||||||
|
React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]),
|
||||||
|
);
|
||||||
|
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||||
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||||||
const isMissingDirectory = directoryState === 'missing';
|
const isMissingDirectory = directoryState === 'missing';
|
||||||
const memoryState = sessionMemoryState.get(session.id);
|
|
||||||
const isActive = currentSessionId === session.id;
|
const isActive = currentSessionId === session.id;
|
||||||
const sessionTitle = session.title || 'Untitled Session';
|
const sessionTitle = resolvedSession.title || 'Untitled Session';
|
||||||
const hasChildren = node.children.length > 0;
|
const hasChildren = node.children.length > 0;
|
||||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
||||||
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
|
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||||||
const unseenCount = useSessionUnseenCount(session.id);
|
const unseenCount = useSessionUnseenCount(session.id);
|
||||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||||
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
const sessionSummary = resolvedSession.summary as SessionSummaryMeta | undefined;
|
||||||
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
||||||
const sessionTimestamp = session.time?.updated || session.time?.created || Date.now();
|
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||||||
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp);
|
||||||
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp);
|
||||||
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
const isMenuOpen = openSidebarMenuKey === menuInstanceKey;
|
||||||
@@ -236,9 +290,9 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
const statusType = sessionStatus?.type ?? 'idle';
|
||||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||||
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
const pendingPermissionCount = sessionPermissions.length;
|
||||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||||
const statusMarkerContent = isStreaming
|
const statusMarkerContent = isStreaming
|
||||||
@@ -296,7 +350,7 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const streamingIndicator = memoryState?.isZombie
|
const streamingIndicator = isZombie
|
||||||
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -338,14 +392,14 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
||||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!session.share ? (
|
{!resolvedSession.share ? (
|
||||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
<DropdownMenuItem onClick={() => handleShareSession(resolvedSession)} className="[&>svg]:mr-1">
|
||||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||||
Share
|
Share
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
|
<DropdownMenuItem onClick={() => { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||||
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||||||
@@ -601,3 +655,5 @@ export function SessionNodeItem(props: Props): React.ReactNode {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual);
|
||||||
|
|||||||
@@ -43,8 +43,6 @@ type Props = {
|
|||||||
collapsedProjects: Set<string>;
|
collapsedProjects: Set<string>;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
projectRepoStatus: Map<string, boolean | null>;
|
projectRepoStatus: Map<string, boolean | null>;
|
||||||
hoveredProjectId: string | null;
|
|
||||||
setHoveredProjectId: (id: string | null) => void;
|
|
||||||
isDesktopShellRuntime: boolean;
|
isDesktopShellRuntime: boolean;
|
||||||
stuckProjectHeaders: Set<string>;
|
stuckProjectHeaders: Set<string>;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
@@ -144,7 +142,6 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
|||||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||||
const isActiveProject = projectKey === props.activeProjectId;
|
const isActiveProject = projectKey === props.activeProjectId;
|
||||||
const isHovered = props.hoveredProjectId === projectKey;
|
|
||||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||||
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||||
@@ -164,14 +161,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
|||||||
projectIconBackground={project.iconBackground}
|
projectIconBackground={project.iconBackground}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isActiveProject={isActiveProject}
|
isActiveProject={isActiveProject}
|
||||||
isHovered={isHovered}
|
|
||||||
isRepo={Boolean(isRepo)}
|
isRepo={Boolean(isRepo)}
|
||||||
isDesktopShell={props.isDesktopShellRuntime}
|
isDesktopShell={props.isDesktopShellRuntime}
|
||||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||||
hideDirectoryControls={props.hideDirectoryControls}
|
hideDirectoryControls={props.hideDirectoryControls}
|
||||||
mobileVariant={props.mobileVariant}
|
mobileVariant={props.mobileVariant}
|
||||||
onToggle={() => props.toggleProject(projectKey)}
|
onToggle={() => props.toggleProject(projectKey)}
|
||||||
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||||
props.setActiveMainTab('chat');
|
props.setActiveMainTab('chat');
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ type Args = {
|
|||||||
isVSCode: boolean;
|
isVSCode: boolean;
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
archivedSessions: Session[];
|
archivedSessions: Session[];
|
||||||
sessionsByDirectory: Map<string, Session[]>;
|
|
||||||
getSessionsByDirectory: (directory: string) => Session[];
|
|
||||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,11 +16,25 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
isVSCode,
|
isVSCode,
|
||||||
sessions,
|
sessions,
|
||||||
archivedSessions,
|
archivedSessions,
|
||||||
sessionsByDirectory,
|
|
||||||
getSessionsByDirectory,
|
|
||||||
availableWorktreesByProject,
|
availableWorktreesByProject,
|
||||||
} = args;
|
} = args;
|
||||||
|
|
||||||
|
const sessionsByDirectory = React.useMemo(() => {
|
||||||
|
const next = new Map<string, Session[]>();
|
||||||
|
sessions.forEach((session) => {
|
||||||
|
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||||
|
?? normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||||
|
if (!directory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collection = next.get(directory) ?? [];
|
||||||
|
collection.push(session);
|
||||||
|
next.set(directory, collection);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
const getSessionsForProject = React.useCallback(
|
const getSessionsForProject = React.useCallback(
|
||||||
(project: { normalizedPath: string }) => {
|
(project: { normalizedPath: string }) => {
|
||||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||||
@@ -37,7 +49,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
const collected: Session[] = [];
|
const collected: Session[] = [];
|
||||||
|
|
||||||
directories.forEach((directory) => {
|
directories.forEach((directory) => {
|
||||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory);
|
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? [];
|
||||||
sessionsForDirectory.forEach((session) => {
|
sessionsForDirectory.forEach((session) => {
|
||||||
if (seen.has(session.id)) {
|
if (seen.has(session.id)) {
|
||||||
return;
|
return;
|
||||||
@@ -49,7 +61,7 @@ export const useProjectSessionLists = (args: Args) => {
|
|||||||
|
|
||||||
return collected;
|
return collected;
|
||||||
},
|
},
|
||||||
[availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory],
|
[availableWorktreesByProject, isVSCode, sessionsByDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getArchivedSessionsForProject = React.useCallback(
|
const getArchivedSessionsForProject = React.useCallback(
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ export interface SortableProjectItemProps {
|
|||||||
projectIconBackground?: string;
|
projectIconBackground?: string;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
isActiveProject: boolean;
|
isActiveProject: boolean;
|
||||||
isHovered: boolean;
|
|
||||||
isRepo: boolean;
|
isRepo: boolean;
|
||||||
isDesktopShell: boolean;
|
isDesktopShell: boolean;
|
||||||
isStuck: boolean;
|
isStuck: boolean;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onHoverChange: (hovered: boolean) => void;
|
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
onRenameStart: () => void;
|
onRenameStart: () => void;
|
||||||
@@ -67,14 +65,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
projectIconBackground,
|
projectIconBackground,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
isActiveProject,
|
isActiveProject,
|
||||||
isHovered,
|
|
||||||
isRepo,
|
isRepo,
|
||||||
isDesktopShell,
|
isDesktopShell,
|
||||||
isStuck,
|
isStuck,
|
||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
onToggle,
|
onToggle,
|
||||||
onHoverChange,
|
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
onRenameStart,
|
onRenameStart,
|
||||||
@@ -158,8 +154,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
'w-full text-left group/project select-none',
|
'w-full text-left group/project select-none',
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
||||||
onMouseEnter={() => onHoverChange(true)}
|
|
||||||
onMouseLeave={() => onHoverChange(false)}
|
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
||||||
<Tooltip delayDuration={1500}>
|
<Tooltip delayDuration={1500}>
|
||||||
@@ -172,17 +166,17 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||||
isRepo && !hideDirectoryControls
|
isRepo && !hideDirectoryControls
|
||||||
? (mobileVariant ? 'pr-20' : isHovered ? 'pr-20' : 'pr-7')
|
? (mobileVariant ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||||
: (mobileVariant ? 'pr-14' : isHovered ? 'pr-14' : 'pr-7'),
|
: (mobileVariant ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||||
<span className={cn('hidden text-muted-foreground h-3.5 w-3.5 items-center justify-center', isHovered && 'inline-flex')}>
|
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover/project:inline-flex group-focus-within/project:inline-flex">
|
||||||
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
{isCollapsed ? <RiArrowRightSLine className="h-3.5 w-3.5" /> : <RiArrowDownSLine className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<span
|
<span
|
||||||
className={cn('inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px]', isHovered && 'hidden')}
|
className="inline-flex h-3.5 w-3.5 items-center justify-center overflow-hidden rounded-[3px] group-hover/project:hidden group-focus-within/project:hidden"
|
||||||
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
style={projectIconBackground ? { backgroundColor: projectIconBackground } : undefined}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -194,9 +188,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
) : ProjectIcon ? (
|
) : ProjectIcon ? (
|
||||||
<ProjectIcon className={cn('h-3.5 w-3.5', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
<ProjectIcon className="h-3.5 w-3.5 group-hover/project:hidden group-focus-within/project:hidden" style={iconColor ? { color: iconColor } : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<RiFolderLine className={cn('h-3.5 w-3.5 text-muted-foreground/80', isHovered && 'hidden')} style={iconColor ? { color: iconColor } : undefined} />
|
<RiFolderLine className="h-3.5 w-3.5 text-muted-foreground/80 group-hover/project:hidden group-focus-within/project:hidden" style={iconColor ? { color: iconColor } : undefined} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -227,7 +221,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
|
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity',
|
||||||
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||||
)}
|
)}
|
||||||
aria-label="New worktree"
|
aria-label="New worktree"
|
||||||
>
|
>
|
||||||
@@ -249,7 +243,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||||
isMenuOpen ? 'opacity-100 pointer-events-auto' : mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
isMenuOpen
|
||||||
|
? 'opacity-100 pointer-events-auto'
|
||||||
|
: mobileVariant
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||||
)}
|
)}
|
||||||
aria-label="Project menu"
|
aria-label="Project menu"
|
||||||
onClick={handleMenuTriggerClick}
|
onClick={handleMenuTriggerClick}
|
||||||
@@ -291,7 +289,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||||
mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto',
|
||||||
)}
|
)}
|
||||||
aria-label={isRepo ? 'New draft session' : 'New session'}
|
aria-label={isRepo ? 'New draft session' : 'New session'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,6 +133,20 @@ export const compareSessionsByPinnedAndTime = (
|
|||||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const compareSessionsByPinnedAndCreated = (
|
||||||
|
a: Session,
|
||||||
|
b: Session,
|
||||||
|
pinnedSessionIds: Set<string>,
|
||||||
|
): number => {
|
||||||
|
const aPinned = pinnedSessionIds.has(a.id);
|
||||||
|
const bPinned = pinnedSessionIds.has(b.id);
|
||||||
|
if (aPinned !== bPinned) {
|
||||||
|
return aPinned ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||||
|
};
|
||||||
|
|
||||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||||
const byId = new Map<string, Session>();
|
const byId = new Map<string, Session>();
|
||||||
sessions.forEach((session) => {
|
sessions.forEach((session) => {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ const renderShortcut = (id: string, fallbackCombo: string, overrides: Record<str
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const HelpDialog: React.FC = () => {
|
export const HelpDialog: React.FC = () => {
|
||||||
const { isHelpDialogOpen, setHelpDialogOpen, shortcutOverrides } = useUIStore();
|
const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen);
|
||||||
|
const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen);
|
||||||
|
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||||
const mod = getModifierLabel();
|
const mod = getModifierLabel();
|
||||||
|
|
||||||
const shortcuts: ShortcutSection[] = [
|
const shortcuts: ShortcutSection[] = [
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
|
||||||
export const OpenCodeStatusDialog: React.FC = () => {
|
export const OpenCodeStatusDialog: React.FC = () => {
|
||||||
const {
|
const isOpenCodeStatusDialogOpen = useUIStore((state) => state.isOpenCodeStatusDialogOpen);
|
||||||
isOpenCodeStatusDialogOpen,
|
const setOpenCodeStatusDialogOpen = useUIStore((state) => state.setOpenCodeStatusDialogOpen);
|
||||||
setOpenCodeStatusDialogOpen,
|
const openCodeStatusText = useUIStore((state) => state.openCodeStatusText);
|
||||||
openCodeStatusText,
|
|
||||||
} = useUIStore();
|
|
||||||
|
|
||||||
const handleCopy = React.useCallback(async () => {
|
const handleCopy = React.useCallback(async () => {
|
||||||
if (!openCodeStatusText) {
|
if (!openCodeStatusText) {
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ type ThumbMetrics = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 1000;
|
const USER_SCROLL_INTENT_WINDOW_MS = 1000;
|
||||||
|
const METRIC_EPSILON = 0.5;
|
||||||
|
const EMPTY_THUMB: ThumbMetrics = { length: 0, offset: 0 };
|
||||||
|
|
||||||
|
const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
|
||||||
|
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
|
||||||
|
};
|
||||||
|
|
||||||
export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
||||||
containerRef,
|
containerRef,
|
||||||
@@ -52,6 +58,7 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container;
|
const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container;
|
||||||
const trackInset = 8;
|
const trackInset = 8;
|
||||||
|
|
||||||
|
let nextVertical: ThumbMetrics = EMPTY_THUMB;
|
||||||
if (scrollHeight > clientHeight) {
|
if (scrollHeight > clientHeight) {
|
||||||
const trackLength = Math.max(clientHeight - trackInset * 2, 0);
|
const trackLength = Math.max(clientHeight - trackInset * 2, 0);
|
||||||
const rawThumb = (clientHeight / scrollHeight) * trackLength;
|
const rawThumb = (clientHeight / scrollHeight) * trackLength;
|
||||||
@@ -59,11 +66,11 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
const maxOffset = Math.max(trackLength - length, 0);
|
const maxOffset = Math.max(trackLength - length, 0);
|
||||||
const maxScroll = Math.max(scrollHeight - clientHeight, 1);
|
const maxScroll = Math.max(scrollHeight - clientHeight, 1);
|
||||||
const offset = (scrollTop / maxScroll) * maxOffset;
|
const offset = (scrollTop / maxScroll) * maxOffset;
|
||||||
setVertical({ length, offset });
|
nextVertical = { length, offset };
|
||||||
} else {
|
|
||||||
setVertical({ length: 0, offset: 0 });
|
|
||||||
}
|
}
|
||||||
|
setVertical((prev) => (isSameThumbMetrics(prev, nextVertical) ? prev : nextVertical));
|
||||||
|
|
||||||
|
let nextHorizontal: ThumbMetrics = EMPTY_THUMB;
|
||||||
if (!disableHorizontal && scrollWidth > clientWidth) {
|
if (!disableHorizontal && scrollWidth > clientWidth) {
|
||||||
const trackLength = Math.max(clientWidth - trackInset * 2, 0);
|
const trackLength = Math.max(clientWidth - trackInset * 2, 0);
|
||||||
const rawThumb = (clientWidth / scrollWidth) * trackLength;
|
const rawThumb = (clientWidth / scrollWidth) * trackLength;
|
||||||
@@ -71,10 +78,9 @@ export const OverlayScrollbar: React.FC<OverlayScrollbarProps> = ({
|
|||||||
const maxOffset = Math.max(trackLength - length, 0);
|
const maxOffset = Math.max(trackLength - length, 0);
|
||||||
const maxScroll = Math.max(scrollWidth - clientWidth, 1);
|
const maxScroll = Math.max(scrollWidth - clientWidth, 1);
|
||||||
const offset = (scrollLeft / maxScroll) * maxOffset;
|
const offset = (scrollLeft / maxScroll) * maxOffset;
|
||||||
setHorizontal({ length, offset });
|
nextHorizontal = { length, offset };
|
||||||
} else {
|
|
||||||
setHorizontal({ length: 0, offset: 0 });
|
|
||||||
}
|
}
|
||||||
|
setHorizontal((prev) => (isSameThumbMetrics(prev, nextHorizontal) ? prev : nextHorizontal));
|
||||||
}, [containerRef, minThumbSize, disableHorizontal]);
|
}, [containerRef, minThumbSize, disableHorizontal]);
|
||||||
|
|
||||||
const scheduleMetricsUpdate = React.useCallback(() => {
|
const scheduleMetricsUpdate = React.useCallback(() => {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { useWorkerPool } from '@/contexts/DiffWorkerProvider';
|
|||||||
import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
|
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -209,7 +208,6 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false);
|
const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false);
|
||||||
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
|
const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true);
|
||||||
|
|
||||||
useUIStore();
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
|
|
||||||
const diffCommentController = useInlineCommentController<SelectedLineRange>({
|
const diffCommentController = useInlineCommentController<SelectedLineRange>({
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|||||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
|
||||||
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments';
|
||||||
|
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
@@ -87,7 +86,6 @@ export const PlanView: React.FC = () => {
|
|||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||||
const runtimeApis = useRuntimeAPIs();
|
const runtimeApis = useRuntimeAPIs();
|
||||||
useUIStore();
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVoiceContext } from '@/hooks/useVoiceContext';
|
import { useVoiceContext } from '@/hooks/useVoiceContext';
|
||||||
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
|
|
||||||
|
const VoiceContextBridge = React.memo(function VoiceContextBridge() {
|
||||||
|
useVoiceContext();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider component that initializes voice context sync.
|
* Provider component that initializes voice context sync.
|
||||||
@@ -13,8 +19,12 @@ import { useVoiceContext } from '@/hooks/useVoiceContext';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function VoiceProvider({ children }: { children: React.ReactNode }) {
|
export function VoiceProvider({ children }: { children: React.ReactNode }) {
|
||||||
// Activate session-to-voice sync
|
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||||
useVoiceContext();
|
|
||||||
|
return (
|
||||||
return <>{children}</>;
|
<>
|
||||||
|
{voiceModeEnabled ? <VoiceContextBridge /> : null}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,19 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
|
|||||||
const sendMessage = useSessionUIStore((s) => s.sendMessage);
|
const sendMessage = useSessionUIStore((s) => s.sendMessage);
|
||||||
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||||
const createSession = useSessionUIStore((s) => s.createSession);
|
const createSession = useSessionUIStore((s) => s.createSession);
|
||||||
const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore();
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
|
const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled);
|
||||||
|
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||||
|
const speechRate = useConfigStore((state) => state.speechRate);
|
||||||
|
const speechPitch = useConfigStore((state) => state.speechPitch);
|
||||||
|
const speechVolume = useConfigStore((state) => state.speechVolume);
|
||||||
|
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||||
|
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||||
|
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||||
|
const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation);
|
||||||
|
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||||
|
|
||||||
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
|
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
|
||||||
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
|
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
|
||||||
|
|||||||
@@ -457,7 +457,48 @@ export const useChatScrollManager = ({
|
|||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container || typeof ResizeObserver === 'undefined') return;
|
if (!container || typeof ResizeObserver === 'undefined') return;
|
||||||
|
|
||||||
|
let lastScrollHeight = container.scrollHeight;
|
||||||
|
let lastClientHeight = container.clientHeight;
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
|
const nextScrollHeight = container.scrollHeight;
|
||||||
|
const nextClientHeight = container.clientHeight;
|
||||||
|
const scrollHeightChanged = nextScrollHeight !== lastScrollHeight;
|
||||||
|
const clientHeightChanged = nextClientHeight !== lastClientHeight;
|
||||||
|
|
||||||
|
if (clientHeightChanged) {
|
||||||
|
const previousDistanceFromBottom = Math.max(
|
||||||
|
0,
|
||||||
|
lastScrollHeight - lastScrollTopRef.current - lastClientHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPinnedRef.current) {
|
||||||
|
const targetScrollTop = Math.max(
|
||||||
|
0,
|
||||||
|
nextScrollHeight - nextClientHeight - previousDistanceFromBottom,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) {
|
||||||
|
markProgrammaticScroll();
|
||||||
|
container.scrollTop = targetScrollTop;
|
||||||
|
lastScrollTopRef.current = targetScrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollHeight = nextScrollHeight;
|
||||||
|
lastClientHeight = nextClientHeight;
|
||||||
|
updateScrollButtonVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollHeight = nextScrollHeight;
|
||||||
|
lastClientHeight = nextClientHeight;
|
||||||
|
|
||||||
|
if (clientHeightChanged && !scrollHeightChanged) {
|
||||||
|
updateScrollButtonVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
schedulePinnedStateAndIndicators();
|
schedulePinnedStateAndIndicators();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -474,7 +515,7 @@ export const useChatScrollManager = ({
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
childObserver.disconnect();
|
childObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [schedulePinnedStateAndIndicators]);
|
}, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ export const useEdgeSwipe = (options: EdgeSwipeOptions = {}) => {
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const {
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
isMobile,
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
setSessionSwitcherOpen,
|
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||||
isSessionSwitcherOpen,
|
|
||||||
} = useUIStore();
|
|
||||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSessions } from '@/sync/sync-context';
|
import { useSessionDirectory } from '@/sync/sync-context';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
|
* Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal).
|
||||||
@@ -18,7 +17,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
export const useEffectiveDirectory = (): string | undefined => {
|
export const useEffectiveDirectory = (): string | undefined => {
|
||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
const sessions = useSessions();
|
const currentSessionDirectory = useSessionDirectory(currentSessionId);
|
||||||
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
||||||
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
|
|
||||||
@@ -28,12 +27,8 @@ export const useEffectiveDirectory = (): string | undefined => {
|
|||||||
if (worktreeMetadata?.path) {
|
if (worktreeMetadata?.path) {
|
||||||
return worktreeMetadata.path;
|
return worktreeMetadata.path;
|
||||||
}
|
}
|
||||||
|
if (currentSessionDirectory) {
|
||||||
const currentSession = sessions.find((session) => session.id === currentSessionId);
|
return currentSessionDirectory;
|
||||||
type SessionWithDirectory = Session & { directory?: string };
|
|
||||||
const sessionDirectory = (currentSession as SessionWithDirectory | undefined)?.directory;
|
|
||||||
if (sessionDirectory) {
|
|
||||||
return sessionDirectory;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,18 +24,16 @@ export interface UseMessageTTSReturn {
|
|||||||
export function useMessageTTS(): UseMessageTTSReturn {
|
export function useMessageTTS(): UseMessageTTSReturn {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
|
||||||
const {
|
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||||
voiceProvider,
|
const speechRate = useConfigStore((state) => state.speechRate);
|
||||||
speechRate,
|
const speechPitch = useConfigStore((state) => state.speechPitch);
|
||||||
speechPitch,
|
const speechVolume = useConfigStore((state) => state.speechVolume);
|
||||||
speechVolume,
|
const sayVoice = useConfigStore((state) => state.sayVoice);
|
||||||
sayVoice,
|
const browserVoice = useConfigStore((state) => state.browserVoice);
|
||||||
browserVoice,
|
const openaiVoice = useConfigStore((state) => state.openaiVoice);
|
||||||
openaiVoice,
|
const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS);
|
||||||
summarizeMessageTTS,
|
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||||
summarizeCharacterThreshold,
|
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||||
showMessageTTSButtons,
|
|
||||||
} = useConfigStore();
|
|
||||||
|
|
||||||
const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai';
|
const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai';
|
||||||
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say';
|
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface ModelListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useModelLists = () => {
|
export const useModelLists = () => {
|
||||||
const { providers } = useConfigStore();
|
const providers = useConfigStore((state) => state.providers);
|
||||||
const favoriteModels = useUIStore((state) => state.favoriteModels);
|
const favoriteModels = useUIStore((state) => state.favoriteModels);
|
||||||
const recentModels = useUIStore((state) => state.recentModels);
|
const recentModels = useUIStore((state) => state.recentModels);
|
||||||
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
const hiddenModels = useUIStore((state) => state.hiddenModels);
|
||||||
|
|||||||
@@ -125,7 +125,12 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
|||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// Get current model, threshold, and max length from config store for summarization
|
// Get current model, threshold, and max length from config store for summarization
|
||||||
const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel } = useConfigStore();
|
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||||
|
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||||
|
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
|
||||||
|
const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength);
|
||||||
|
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
|
||||||
|
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
|
||||||
|
|
||||||
// Check if server TTS is available
|
// Check if server TTS is available
|
||||||
const checkAvailability = useCallback(async (): Promise<boolean> => {
|
const checkAvailability = useCallback(async (): Promise<boolean> => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react"
|
import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react"
|
||||||
import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Session } from "@opencode-ai/sdk/v2"
|
||||||
import type { StoreApi } from "zustand"
|
import type { StoreApi } from "zustand"
|
||||||
import { useStore } from "zustand"
|
import { useStore } from "zustand"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -681,6 +682,145 @@ export function useSessions(directory?: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => {
|
||||||
|
const directory = (session as Session & { directory?: string | null }).directory ?? ''
|
||||||
|
const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
|
||||||
|
const projectWorktree = (session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? ''
|
||||||
|
const shared = session.share?.url ?? ''
|
||||||
|
return [
|
||||||
|
session.id,
|
||||||
|
session.title ?? '',
|
||||||
|
session.time?.created ?? 0,
|
||||||
|
session.time?.archived ? 1 : 0,
|
||||||
|
directory,
|
||||||
|
parentID,
|
||||||
|
projectWorktree,
|
||||||
|
shared,
|
||||||
|
stableUpdatedAt,
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get sessions stabilized for sidebar tree rendering */
|
||||||
|
export function useSidebarSessions(directory?: string): Session[] {
|
||||||
|
const store = useDirectoryStore(directory)
|
||||||
|
const cacheRef = React.useRef<{
|
||||||
|
source: Session[]
|
||||||
|
streamingSignature: string
|
||||||
|
array: Session[]
|
||||||
|
signatures: Map<string, string>
|
||||||
|
sessionsById: Map<string, Session>
|
||||||
|
stableUpdatedAtById: Map<string, number>
|
||||||
|
streamingById: Map<string, boolean>
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const getSnapshot = React.useCallback(() => {
|
||||||
|
const state = store.getState()
|
||||||
|
const source = state.session
|
||||||
|
const cached = cacheRef.current
|
||||||
|
const streamingSignature = source
|
||||||
|
.map((session) => {
|
||||||
|
const statusType = state.session_status?.[session.id]?.type
|
||||||
|
const isStreaming = statusType === 'busy' || statusType === 'retry'
|
||||||
|
return `${session.id}:${isStreaming ? 1 : 0}`
|
||||||
|
})
|
||||||
|
.join('|')
|
||||||
|
|
||||||
|
if (cached && cached.source === source && cached.streamingSignature === streamingSignature) {
|
||||||
|
return cached.array
|
||||||
|
}
|
||||||
|
|
||||||
|
const signatures = new Map<string, string>()
|
||||||
|
const sessionsById = new Map<string, Session>()
|
||||||
|
const stableUpdatedAtById = new Map<string, number>()
|
||||||
|
const streamingById = new Map<string, boolean>()
|
||||||
|
let changed = !cached || cached.array.length !== source.length
|
||||||
|
|
||||||
|
const array = source.map((session) => {
|
||||||
|
const rawUpdatedAt = Number(session.time?.updated ?? session.time?.created ?? 0)
|
||||||
|
const statusType = state.session_status?.[session.id]?.type
|
||||||
|
const isStreaming = statusType === 'busy' || statusType === 'retry'
|
||||||
|
const cachedUpdatedAt = cached?.stableUpdatedAtById.get(session.id) ?? rawUpdatedAt
|
||||||
|
const wasStreaming = cached?.streamingById.get(session.id) ?? false
|
||||||
|
const stableUpdatedAt = isStreaming
|
||||||
|
? (wasStreaming ? cachedUpdatedAt : Math.max(rawUpdatedAt, cachedUpdatedAt, Date.now()))
|
||||||
|
: cachedUpdatedAt
|
||||||
|
const signature = getSidebarSessionSignature(session, stableUpdatedAt)
|
||||||
|
signatures.set(session.id, signature)
|
||||||
|
stableUpdatedAtById.set(session.id, stableUpdatedAt)
|
||||||
|
streamingById.set(session.id, isStreaming)
|
||||||
|
|
||||||
|
const cachedSession = cached?.sessionsById.get(session.id)
|
||||||
|
if (
|
||||||
|
cachedSession
|
||||||
|
&& cached?.signatures.get(session.id) === signature
|
||||||
|
) {
|
||||||
|
sessionsById.set(session.id, cachedSession)
|
||||||
|
return cachedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true
|
||||||
|
const nextSession = stableUpdatedAt === rawUpdatedAt
|
||||||
|
? session
|
||||||
|
: {
|
||||||
|
...session,
|
||||||
|
time: {
|
||||||
|
...session.time,
|
||||||
|
updated: stableUpdatedAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sessionsById.set(session.id, nextSession)
|
||||||
|
return nextSession
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!changed && cached) {
|
||||||
|
cacheRef.current = {
|
||||||
|
source,
|
||||||
|
streamingSignature,
|
||||||
|
array: cached.array,
|
||||||
|
signatures,
|
||||||
|
sessionsById: cached.sessionsById,
|
||||||
|
stableUpdatedAtById,
|
||||||
|
streamingById,
|
||||||
|
}
|
||||||
|
return cached.array
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRef.current = { source, streamingSignature, array, signatures, sessionsById, stableUpdatedAtById, streamingById }
|
||||||
|
return array
|
||||||
|
}, [store])
|
||||||
|
|
||||||
|
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get one session by id for a directory */
|
||||||
|
export function useSession(sessionID?: string | null, directory?: string) {
|
||||||
|
return useDirectorySync(
|
||||||
|
useCallback(
|
||||||
|
(state: State) => {
|
||||||
|
if (!sessionID) return undefined
|
||||||
|
return state.session.find((session) => session.id === sessionID)
|
||||||
|
},
|
||||||
|
[sessionID],
|
||||||
|
),
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get one session directory by id for a directory */
|
||||||
|
export function useSessionDirectory(sessionID?: string | null, directory?: string): string | undefined {
|
||||||
|
return useDirectorySync(
|
||||||
|
useCallback(
|
||||||
|
(state: State) => {
|
||||||
|
if (!sessionID) return undefined
|
||||||
|
const session = state.session.find((candidate) => candidate.id === sessionID)
|
||||||
|
return (session as (typeof session & { directory?: string | null }) | undefined)?.directory ?? undefined
|
||||||
|
},
|
||||||
|
[sessionID],
|
||||||
|
),
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the SDK client */
|
/** Get the SDK client */
|
||||||
export function useSyncSDK() {
|
export function useSyncSDK() {
|
||||||
return useSyncSystem().sdk
|
return useSyncSystem().sdk
|
||||||
|
|||||||
Reference in New Issue
Block a user