feat: refactor composer to two-layer layout, add session permission & context indicator
Composer restructure: - MessageInput internal footer now only contains: file button, model selector, slash command button, and send button - Removed mode toggle (Code/Plan) from composer area (logic preserved) - New ChatComposerActionBar below input with three slots: left (Design Agent), center (permission selector), right (context indicator) New components: - SlashCommandButton: inserts `/` at cursor, reuses existing slash command detection and popover logic - ChatPermissionSelector: session-level permission toggle (default / full_access) with danger confirmation dialog - ContextUsageIndicator: SVG donut chart showing context usage ratio with HoverCard displaying model, tokens, cache stats - ChatComposerActionBar: slot-based container for action bar items Session permission (permission_profile): - DB: new column on chat_sessions, default 'default' - API: PATCH sessions supports updating permission_profile - Chat API: bypassPermissions = global setting OR session full_access - PermissionPrompt: auto-approves and hides when full_access - Claude client: combines global and per-session bypass flags Bridge integration: - permission-broker: auto-approves permissions for full_access sessions without sending IM notifications - conversation-engine: passes bypassPermissions to SDK for full_access - PATCH route: auto-approves pending bridge permissions on switch to full_access Other: - ImageGenToggle relabeled to "Design Agent" / "设计 Agent" - New model-context.ts with MODEL_CONTEXT_WINDOWS lookup - Provider models API returns optional contextWindow field - New useContextUsage hook for token usage calculation - i18n: added composer.*, permission.*, context.* keys (en + zh) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
9695825007
commit
111fa8c92e
23 files changed
+643
-37
No files matched your search
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ChatComposerActionBarProps {
|
||||
left?: ReactNode;
|
||||
center?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}
|
||||
|
||||
export function ChatComposerActionBar({ left, center, right }: ChatComposerActionBarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{left}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{center}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{right}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { HugeiconsIcon } from '@hugeicons/react';
|
||||
import { LockIcon, SquareUnlock02Icon } from '@hugeicons/core-free-icons';
|
||||
|
||||
interface ChatPermissionSelectorProps {
|
||||
sessionId: string;
|
||||
permissionProfile: 'default' | 'full_access';
|
||||
onPermissionChange: (profile: 'default' | 'full_access') => void;
|
||||
}
|
||||
|
||||
export function ChatPermissionSelector({
|
||||
sessionId,
|
||||
permissionProfile,
|
||||
onPermissionChange,
|
||||
}: ChatPermissionSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
const handleSelect = (profile: 'default' | 'full_access') => {
|
||||
if (profile === 'full_access' && permissionProfile !== 'full_access') {
|
||||
setShowWarning(true);
|
||||
return;
|
||||
}
|
||||
applyChange(profile);
|
||||
};
|
||||
|
||||
const applyChange = async (profile: 'default' | 'full_access') => {
|
||||
try {
|
||||
await fetch(`/api/chat/sessions/${sessionId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ permission_profile: profile }),
|
||||
});
|
||||
onPermissionChange(profile);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
const isFullAccess = permissionProfile === 'full_access';
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
isFullAccess
|
||||
? 'bg-orange-500/15 text-orange-600 dark:text-orange-400 hover:bg-orange-500/25'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={isFullAccess ? SquareUnlock02Icon : LockIcon}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>
|
||||
{isFullAccess ? t('permission.fullAccess') : t('permission.default')}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[140px]">
|
||||
<DropdownMenuItem onClick={() => handleSelect('default')}>
|
||||
<HugeiconsIcon icon={LockIcon} className="h-3.5 w-3.5" />
|
||||
<span>{t('permission.default')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleSelect('full_access')}>
|
||||
<HugeiconsIcon icon={SquareUnlock02Icon} className="h-3.5 w-3.5 text-orange-500" />
|
||||
<span>{t('permission.fullAccess')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={showWarning} onOpenChange={setShowWarning}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('permission.fullAccess')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('permission.fullAccessWarning')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowWarning(false);
|
||||
applyChange('full_access');
|
||||
}}
|
||||
>
|
||||
{t('permission.fullAccess')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { useRouter } from 'next/navigation';
|
||||
import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { ChatComposerActionBar } from './ChatComposerActionBar';
|
||||
import { ChatPermissionSelector } from './ChatPermissionSelector';
|
||||
import { ContextUsageIndicator } from './ContextUsageIndicator';
|
||||
import { ImageGenToggle } from './ImageGenToggle';
|
||||
import { usePanel } from '@/hooks/usePanel';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { PermissionPrompt } from './PermissionPrompt';
|
||||
@@ -26,13 +30,15 @@ interface ChatViewProps {
|
||||
modelName?: string;
|
||||
initialMode?: string;
|
||||
providerId?: string;
|
||||
initialPermissionProfile?: 'default' | 'full_access';
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId }: ChatViewProps) {
|
||||
export function ChatView({ sessionId, initialMessages = [], initialHasMore = false, modelName, initialMode, providerId, initialPermissionProfile }: ChatViewProps) {
|
||||
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>(initialPermissionProfile || 'default');
|
||||
|
||||
// Workspace mismatch banner state
|
||||
const [workspaceMismatchPath, setWorkspaceMismatchPath] = useState<string | null>(null);
|
||||
@@ -52,6 +58,11 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
useEffect(() => {
|
||||
setCurrentProviderId(providerId || (typeof window !== 'undefined' ? localStorage.getItem('codepilot:last-provider-id') : null) || '');
|
||||
}, [providerId]);
|
||||
useEffect(() => {
|
||||
if (initialPermissionProfile) {
|
||||
setPermissionProfile(initialPermissionProfile);
|
||||
}
|
||||
}, [initialPermissionProfile]);
|
||||
|
||||
// Stream snapshot from the manager — drives all streaming UI
|
||||
const [streamSnapshot, setStreamSnapshot] = useState<SessionStreamSnapshot | null>(
|
||||
@@ -659,6 +670,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
permissionResolved={permissionResolved}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
toolUses={toolUses}
|
||||
permissionProfile={permissionProfile}
|
||||
/>
|
||||
{/* Batch image generation panels — shown above the input area */}
|
||||
<BatchExecutionDashboard />
|
||||
@@ -681,6 +693,22 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal
|
||||
onModeChange={handleModeChange}
|
||||
onAssistantTrigger={checkAssistantTrigger}
|
||||
/>
|
||||
<ChatComposerActionBar
|
||||
left={<ImageGenToggle />}
|
||||
center={
|
||||
<ChatPermissionSelector
|
||||
sessionId={sessionId}
|
||||
permissionProfile={permissionProfile}
|
||||
onPermissionChange={setPermissionProfile}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
<ContextUsageIndicator
|
||||
messages={messages}
|
||||
modelName={currentModel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import type { Message } from '@/types';
|
||||
import { useContextUsage } from '@/hooks/useContextUsage';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardContent,
|
||||
} from '@/components/ui/hover-card';
|
||||
|
||||
interface ContextUsageIndicatorProps {
|
||||
messages: Message[];
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const usage = useContextUsage(messages, modelName);
|
||||
|
||||
const size = 22;
|
||||
const strokeWidth = 3;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - usage.ratio * circumference;
|
||||
|
||||
// Color based on usage ratio
|
||||
let strokeColor = 'text-muted-foreground';
|
||||
if (usage.hasData) {
|
||||
if (usage.ratio > 0.8) strokeColor = 'text-red-500';
|
||||
else if (usage.ratio > 0.6) strokeColor = 'text-yellow-500';
|
||||
else strokeColor = 'text-primary';
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button className="flex items-center justify-center p-1 rounded hover:bg-muted transition-colors" type="button">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
className="stroke-muted"
|
||||
/>
|
||||
{/* Usage arc */}
|
||||
{usage.hasData && usage.ratio > 0 && (
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className={`${strokeColor} transition-all`}
|
||||
style={{ stroke: 'currentColor' }}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="top" align="center" className="w-56 p-3 text-xs">
|
||||
{!usage.hasData ? (
|
||||
<p className="text-muted-foreground">{t('context.noData')}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.model')}</span>
|
||||
<span className="font-medium">{usage.modelName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.used')}</span>
|
||||
<span className="font-medium">{formatTokens(usage.used)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.total')}</span>
|
||||
<span className="font-medium">
|
||||
{usage.contextWindow ? formatTokens(usage.contextWindow) : t('context.unknown')}
|
||||
</span>
|
||||
</div>
|
||||
{usage.contextWindow && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.percentage')}</span>
|
||||
<span className="font-medium">{(usage.ratio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-border pt-1.5 mt-1.5 space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.cacheRead')}</span>
|
||||
<span>{formatTokens(usage.cacheReadTokens)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.cacheCreation')}</span>
|
||||
<span>{formatTokens(usage.cacheCreationTokens)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('context.outputTokens')}</span>
|
||||
<span>{formatTokens(usage.outputTokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground pt-1 border-t border-border">
|
||||
{t('context.estimate')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
@@ -31,11 +31,11 @@ export function ImageGenToggle() {
|
||||
: 'text-muted-foreground border-border/60 hover:text-foreground hover:border-foreground/30 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
{t('imageGen.toggleLabel' as TranslationKey)}
|
||||
{t('composer.designAgent' as TranslationKey)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('imageGen.toggleTooltip' as TranslationKey)}
|
||||
{t('composer.designAgentTooltip' as TranslationKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment, ProviderModelGroup } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ImageGenToggle } from './ImageGenToggle';
|
||||
import { SlashCommandButton } from './SlashCommandButton';
|
||||
import { useImageGen } from '@/hooks/useImageGen';
|
||||
import { PENDING_KEY, setRefImages, deleteRefImages } from '@/lib/image-ref-store';
|
||||
|
||||
@@ -612,6 +612,23 @@ export function MessageInput({
|
||||
}
|
||||
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
|
||||
|
||||
// Insert `/` into textarea to trigger slash command popover
|
||||
const handleInsertSlash = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const before = inputValue.slice(0, cursorPos);
|
||||
const after = inputValue.slice(cursorPos);
|
||||
const newValue = before + '/' + after;
|
||||
setInputValue(newValue);
|
||||
handleInputChange(newValue);
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = cursorPos + 1;
|
||||
textarea.selectionEnd = cursorPos + 1;
|
||||
}, 0);
|
||||
}, [inputValue, handleInputChange]);
|
||||
|
||||
const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const content = inputValue.trim();
|
||||
@@ -1119,27 +1136,6 @@ export function MessageInput({
|
||||
{/* Attach file button */}
|
||||
<AttachFileButton />
|
||||
|
||||
{/* Mode capsule toggle */}
|
||||
<div className="flex items-center rounded-full border border-border/60 overflow-hidden h-7">
|
||||
{MODE_OPTIONS.map((opt) => {
|
||||
const isActive = opt.value === mode;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => onModeChange?.(opt.value)}
|
||||
>
|
||||
{opt.value === 'code' ? t('messageInput.modeCode') : opt.value === 'plan' ? t('messageInput.modePlan') : opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Model selector */}
|
||||
<div className="relative" ref={modelMenuRef}>
|
||||
<PromptInputButton
|
||||
@@ -1191,8 +1187,8 @@ export function MessageInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Agent toggle */}
|
||||
<ImageGenToggle />
|
||||
{/* Slash command button */}
|
||||
<SlashCommandButton onInsertSlash={handleInsertSlash} />
|
||||
</PromptInputTools>
|
||||
|
||||
<FileAwareSubmitButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import {
|
||||
MessageResponse,
|
||||
@@ -35,6 +35,7 @@ interface PermissionPromptProps {
|
||||
permissionResolved: 'allow' | 'deny' | null;
|
||||
onPermissionResponse: (decision: 'allow' | 'allow_session' | 'deny', updatedInput?: Record<string, unknown>, denyMessage?: string) => void;
|
||||
toolUses?: ToolUseInfo[];
|
||||
permissionProfile?: 'default' | 'full_access';
|
||||
}
|
||||
|
||||
function AskUserQuestionUI({
|
||||
@@ -303,9 +304,27 @@ export function PermissionPrompt({
|
||||
permissionResolved,
|
||||
onPermissionResponse,
|
||||
toolUses = [],
|
||||
permissionProfile,
|
||||
}: PermissionPromptProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Auto-approve when full_access is active
|
||||
const autoApprovedRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
permissionProfile === 'full_access' &&
|
||||
pendingPermission &&
|
||||
!permissionResolved &&
|
||||
autoApprovedRef.current !== pendingPermission.permissionRequestId
|
||||
) {
|
||||
autoApprovedRef.current = pendingPermission.permissionRequestId;
|
||||
onPermissionResponse('allow');
|
||||
}
|
||||
}, [permissionProfile, pendingPermission, permissionResolved, onPermissionResponse]);
|
||||
|
||||
// Don't render permission UI when full_access
|
||||
if (permissionProfile === 'full_access') return null;
|
||||
|
||||
// Nothing to show
|
||||
if (!pendingPermission && !permissionResolved) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { CommandLineIcon } from "@hugeicons/core-free-icons";
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { TranslationKey } from '@/i18n';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { PromptInputButton } from '@/components/ai-elements/prompt-input';
|
||||
|
||||
interface SlashCommandButtonProps {
|
||||
onInsertSlash: () => void;
|
||||
}
|
||||
|
||||
export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PromptInputButton onClick={onInsertSlash}>
|
||||
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5" />
|
||||
</PromptInputButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('composer.slashCommandTooltip' as TranslationKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user