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:
7418andClaude Opus 4.6 committed 2026-03-07 14:55:38 +08:00
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>
</>
);
}
+29 -1
View File
@@ -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>
);
}
+2 -2
View File
@@ -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>
);
+20 -24
View File
@@ -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
+20 -1
View File
@@ -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>
);
}