View model interactions (#9806)

Signed-off-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Douwe M Osinga <douwe@sidewalklabs.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2026-06-18 20:28:03 -04:00
committed by GitHub
parent 09c8d2be5a
commit 08fb6d45f3
18 changed files with 676 additions and 116 deletions
+2 -1
View File
@@ -106,6 +106,7 @@ export default function BaseChat({
messages,
chatState,
setChatState,
updateSession,
handleSubmit,
submitElicitationResponse,
stopStreaming,
@@ -434,7 +435,7 @@ export default function BaseChat({
<EnvironmentBadge className="translate-y-px" />
</div>
<SessionActionsHeader session={session} />
<SessionActionsHeader session={session} onSessionChange={updateSession} />
<ScrollArea
ref={scrollRef}
+23 -19
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { IpcRendererEvent } from 'electron';
import { Outlet, useLocation } from 'react-router-dom';
import { motion } from 'framer-motion';
import { Menu } from 'lucide-react';
import { Menu, PanelLeft } from 'lucide-react';
import { defineMessages, useIntl } from '../../i18n';
import { Button } from '../ui/button';
import ChatSessionsContainer from '../ChatSessionsContainer';
@@ -18,6 +18,10 @@ const i18n = defineMessages({
id: 'appLayout.openNavigation',
defaultMessage: 'Open navigation',
},
collapseNavigation: {
id: 'appLayout.collapseNavigation',
defaultMessage: 'Collapse navigation',
},
});
interface AppLayoutContentProps {
@@ -60,28 +64,28 @@ const AppLayoutContent: React.FC<AppLayoutContentProps> = ({ activeSessions }) =
const needsTrafficLightInset = safeIsMacOS && !isFullScreen;
const headerPadding = needsTrafficLightInset ? 'pl-[96px]' : 'pl-4';
const headerTop = needsTrafficLightInset ? 'top-[15px]' : 'top-[11px]';
const headerTop = needsTrafficLightInset ? 'top-[14px]' : 'top-[11px]';
const navToggleTitle = intl.formatMessage(
isNavExpanded ? i18n.collapseNavigation : i18n.openNavigation
);
return (
<div className="flex flex-1 w-full h-full relative animate-fade-in bg-background-primary flex-row">
{/* Floating menu toggle — only when sidebar is collapsed. When expanded,
the sidebar's own header has the collapse button. */}
{!isNavExpanded && (
<div
style={{ zIndex: Z_INDEX.HEADER }}
className={cn('absolute flex items-center gap-1', headerPadding, headerTop, 'ml-1.5')}
<div
style={{ zIndex: Z_INDEX.HEADER }}
className={cn('absolute flex items-center gap-1', headerPadding, headerTop, 'ml-1.5')}
>
<Button
onClick={() => setIsNavExpanded(!isNavExpanded)}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title={navToggleTitle}
aria-label={navToggleTitle}
>
<Button
onClick={() => setIsNavExpanded(true)}
className="no-drag hover:!bg-background-tertiary"
variant="ghost"
size="xs"
title={intl.formatMessage(i18n.openNavigation)}
>
<Menu className="w-5 h-5" />
</Button>
</div>
)}
{isNavExpanded ? <PanelLeft className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
</div>
{/* Main content with navigation. Shared white canvas; the sidebar is a
rounded outlined card floating on it with breathing room. */}
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ChevronDown, ChevronRight, PanelLeft } from 'lucide-react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { motion } from 'framer-motion';
import { useNavigationContext } from './NavigationContext';
import { useConfig } from '../ConfigContext';
@@ -12,7 +12,6 @@ import {
type NavItem,
} from '../../hooks/useNavigationItems';
import { AppEvents } from '../../constants/events';
import { Goose } from '../icons/Goose';
import { InlineEditText } from '../common/InlineEditText';
import { SessionIndicators } from '../SessionIndicators';
import { acpRenameSession, type SessionListItem } from '../../acp/sessions';
@@ -39,10 +38,6 @@ const i18n = defineMessages({
id: 'navigationPanel.untitledSession',
defaultMessage: 'Untitled session',
},
collapseSidebar: {
id: 'navigationPanel.collapseSidebar',
defaultMessage: 'Collapse sidebar',
},
});
const navItemClass = (active: boolean) =>
@@ -124,7 +119,7 @@ const SessionRow: React.FC<SessionRowProps> = ({ session, active, status, onClic
export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const intl = useIntl();
const { isNavExpanded, setIsNavExpanded } = useNavigationContext();
const { isNavExpanded } = useNavigationContext();
const location = useLocation();
const { extensionsList } = useConfig();
@@ -198,20 +193,10 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
transition={{ duration: 0.15 }}
className={cn('bg-background-primary outline-none flex flex-col h-full', className)}
>
{/* Header: logo + collapse button. Top padding clears the macOS traffic lights. */}
<div className="flex items-center justify-between px-4 pt-[34px] pb-2 no-drag">
<Goose className="w-6 h-6 text-text-primary" />
<button
onClick={() => setIsNavExpanded(false)}
className="p-1.5 rounded-md hover:bg-background-tertiary transition-colors"
title={intl.formatMessage(i18n.collapseSidebar)}
>
<PanelLeft className="w-4 h-4 text-text-secondary" />
</button>
</div>
<div className="h-[48px] no-drag" />
{/* Nav items */}
<div className="px-2 pt-2 flex flex-col gap-0.5">
<div className="px-2 flex flex-col gap-0.5">
{visibleItems.map((item) => (
<NavRow
key={item.id}
@@ -1,20 +1,75 @@
import { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, FileJson, LoaderCircle } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
import { ChevronDown, ChevronRight, Copy, Edit2, FileJson, LoaderCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { AppEvents } from '../constants/events';
import { defineMessages, useIntl } from '../i18n';
import { acpExportSession } from '../acp/sessions';
import { acpExportSession, acpForkSession, acpRenameSession } from '../acp/sessions';
import type { Session } from '../api';
import { getSessionDisplayName } from '../sessions';
import { errorMessage } from '../utils/conversionUtils';
import { cn } from '../utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
const i18n = defineMessages({
actionsLabel: {
id: 'sessionActionsHeader.actionsLabel',
defaultMessage: 'Session actions',
},
renameSession: {
id: 'sessionActionsHeader.renameSession',
defaultMessage: 'Rename session',
},
duplicateSession: {
id: 'sessionActionsHeader.duplicateSession',
defaultMessage: 'Duplicate session',
},
viewJson: {
id: 'sessionActionsHeader.viewJson',
defaultMessage: 'View session JSON',
},
renameTitle: {
id: 'sessionActionsHeader.renameTitle',
defaultMessage: 'Rename Session',
},
renamePlaceholder: {
id: 'sessionActionsHeader.renamePlaceholder',
defaultMessage: 'Enter session name',
},
cancel: {
id: 'sessionActionsHeader.cancel',
defaultMessage: 'Cancel',
},
save: {
id: 'sessionActionsHeader.save',
defaultMessage: 'Save',
},
saving: {
id: 'sessionActionsHeader.saving',
defaultMessage: 'Saving...',
},
renamed: {
id: 'sessionActionsHeader.renamed',
defaultMessage: 'Session renamed',
},
renameFailed: {
id: 'sessionActionsHeader.renameFailed',
defaultMessage: 'Failed to rename session: {error}',
},
duplicated: {
id: 'sessionActionsHeader.duplicated',
defaultMessage: 'Session duplicated',
},
duplicateFailed: {
id: 'sessionActionsHeader.duplicateFailed',
defaultMessage: 'Failed to duplicate session: {error}',
},
jsonTitle: {
id: 'sessionActionsHeader.jsonTitle',
defaultMessage: 'Session JSON',
@@ -59,6 +114,7 @@ const STRING_PREVIEW_END = 56;
interface SessionActionsHeaderProps {
session?: Session;
onSessionChange: (updater: (session: Session) => Session) => void;
className?: string;
}
@@ -253,16 +309,82 @@ function JsonTree({
);
}
export default function SessionActionsHeader({ session, className }: SessionActionsHeaderProps) {
export default function SessionActionsHeader({
session,
onSessionChange,
className,
}: SessionActionsHeaderProps) {
const intl = useIntl();
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [isRenaming, setIsRenaming] = useState(false);
const [isJsonOpen, setIsJsonOpen] = useState(false);
const [jsonValue, setJsonValue] = useState<unknown>(null);
const [jsonText, setJsonText] = useState('');
const [isJsonLoading, setIsJsonLoading] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [fullTextSelection, setFullTextSelection] = useState<FullTextSelection | null>(null);
const title = useMemo(() => (session ? getSessionDisplayName(session) : ''), [session]);
useEffect(() => {
if (session && isRenameOpen) {
setRenameValue(getSessionDisplayName(session));
}
}, [isRenameOpen, session]);
const handleRename = useCallback(async () => {
if (!session || isRenaming) return;
const trimmedName = renameValue.trim();
if (!trimmedName) return;
if (trimmedName === session.name) {
setIsRenameOpen(false);
return;
}
setIsRenaming(true);
try {
await acpRenameSession(session.id, trimmedName);
onSessionChange((current) => ({ ...current, name: trimmedName, user_set_name: true }));
window.dispatchEvent(
new CustomEvent(AppEvents.SESSION_RENAMED, {
detail: { sessionId: session.id, newName: trimmedName, userInitiated: true },
})
);
setIsRenameOpen(false);
toast.success(intl.formatMessage(i18n.renamed));
} catch (error) {
toast.error(
intl.formatMessage(i18n.renameFailed, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
setIsRenaming(false);
}
}, [intl, isRenaming, onSessionChange, renameValue, session]);
const handleDuplicate = useCallback(async () => {
if (!session || isDuplicating) return;
setIsDuplicating(true);
try {
await acpForkSession(session.id);
window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED));
toast.success(intl.formatMessage(i18n.duplicated));
} catch (error) {
toast.error(
intl.formatMessage(i18n.duplicateFailed, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
setIsDuplicating(false);
}
}, [intl, isDuplicating, session]);
const handleViewJson = useCallback(async () => {
if (!session) return;
@@ -306,6 +428,15 @@ export default function SessionActionsHeader({ session, className }: SessionActi
}
}, []);
const handleRenameKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
void handleRename();
}
},
[handleRename]
);
if (!session) {
return null;
}
@@ -318,22 +449,69 @@ export default function SessionActionsHeader({ session, className }: SessionActi
className
)}
>
<button
type="button"
className="flex h-7 max-w-full items-center gap-1 rounded-md px-2.5 text-text-primary transition-colors hover:bg-background-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-active"
aria-label={intl.formatMessage(i18n.viewJson)}
title={intl.formatMessage(i18n.viewJson)}
onClick={() => void handleViewJson()}
>
<span className="truncate text-xs font-medium">{title}</span>
{isJsonLoading ? (
<LoaderCircle className="size-3.5 shrink-0 animate-spin text-text-secondary" />
) : (
<FileJson className="size-3.5 shrink-0 text-text-secondary" />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 max-w-full items-center gap-1 rounded-md px-2.5 text-text-primary transition-colors hover:bg-background-secondary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-active"
aria-label={intl.formatMessage(i18n.actionsLabel)}
>
<span className="truncate text-xs font-medium">{title}</span>
<ChevronDown className="size-3.5 text-text-secondary" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem onSelect={() => setIsRenameOpen(true)}>
<Edit2 className="size-4" />
{intl.formatMessage(i18n.renameSession)}
</DropdownMenuItem>
<DropdownMenuItem disabled={isDuplicating} onSelect={() => void handleDuplicate()}>
{isDuplicating ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Copy className="size-4" />
)}
{intl.formatMessage(i18n.duplicateSession)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleViewJson()}>
{isJsonLoading ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<FileJson className="size-4" />
)}
{intl.formatMessage(i18n.viewJson)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Dialog open={isRenameOpen} onOpenChange={setIsRenameOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{intl.formatMessage(i18n.renameTitle)}</DialogTitle>
</DialogHeader>
<input
type="text"
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
onKeyDown={handleRenameKeyDown}
placeholder={intl.formatMessage(i18n.renamePlaceholder)}
className="w-full rounded-lg border border-border-primary bg-background-primary p-3 text-text-primary outline-none focus:ring-2 focus:ring-border-active"
disabled={isRenaming}
maxLength={200}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRenameOpen(false)} disabled={isRenaming}>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button onClick={() => void handleRename()} disabled={isRenaming || !renameValue.trim()}>
{isRenaming ? intl.formatMessage(i18n.saving) : intl.formatMessage(i18n.save)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isJsonOpen} onOpenChange={handleJsonOpenChange}>
<DialogContent className="grid max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-4xl">
<DialogHeader>