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,51 @@
# Composer 重构 + 单聊天权限 + 远程桥接联动
## 状态: 🟡 进行中
## 概述
重构聊天输入区为两层结构,新增 session 级权限档位,桥接联动,上下文占用指示器。
## 轨道拆分
### Track 1: Backend & Data(数据层)
- [ ] db.ts: chat_sessions 新增 permission_profile TEXT NOT NULL DEFAULT 'default'
- [ ] types/index.ts: Session 类型新增 permission_profile 字段
- [ ] api/chat/sessions/route.ts: 创建 session 时写入 default
- [ ] api/chat/sessions/[id]/route.ts: GET 返回 permission_profile, PATCH 支持更新
- [ ] api/chat/route.ts: bypassPermissions = global || session.permission_profile === 'full_access'
- [ ] lib/model-context.ts: 新建模型 contextWindow 常量表
- [ ] api/providers/models/route.ts: 返回可选 contextWindow 字段
- [ ] i18n/en.ts + zh.ts: 新增所有翻译 key
### Track 2: Composer UI 重构
- [ ] MessageInput.tsx: 内部 footer 只保留文件、模型、slash、发送
- [ ] 移除 mode toggle(保留后端逻辑)
- [ ] 新建 ChatComposerActionBar.tsx: 输入框外部操作栏容器
- [ ] 新建 SlashCommandButton.tsx: 复用现有 slash 逻辑
- [ ] ImageGenToggle 改文案"设计 Agent",移到 action bar
- [ ] ChatView.tsx + page.tsx: 集成新布局
### Track 3: 权限选择器 + 上下文指示器
- [ ] 新建 ChatPermissionSelector.tsx: default/full_access 切换 + 危险确认
- [ ] 新建 ContextUsageIndicator.tsx: 圆圈 + HoverCard
- [ ] 新建 hooks/useContextUsage.ts: 解析 usage、计算 ratio
- [ ] 集成到 ChatComposerActionBar
### Track 4: Bridge 联动
- [ ] permission-broker.ts: 检查 session permission_profile
- [ ] bridge-manager.ts: full_access 时不发权限通知
- [ ] conversation-engine.ts: full_access 时跳过权限卡片
- [ ] 处理切换时已存在的 pending permission 失效
## 依赖关系
- Track 2/3/4 都依赖 Track 1 的类型和 DB 字段
- Track 2 和 Track 3 可并行,最后在 ChatView 集成
- Track 4 独立于 UI 轨道
## 测试清单
- [ ] session 级 permission_profile 持久化
- [ ] slash 按钮触发现有命令流程
- [ ] full_access 下本地不显示权限确认
- [ ] full_access 下 bridge 不发权限通知
- [ ] context hover 展示明细
- [ ] composer 布局回归
+1
View File
@@ -314,6 +314,7 @@ Start by greeting the user and asking the first question.
toolTimeoutSeconds: toolTimeout || 300,
provider: resolvedProvider,
conversationHistory: historyMsgs,
bypassPermissions: session.permission_profile === 'full_access',
onRuntimeStatusChange: (status: string) => {
try { setSessionRuntimeStatus(session_id, status); } catch { /* best effort */ }
},
+17 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server';
import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages, updateSdkSessionId } from '@/lib/db';
import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode, updateSessionModel, updateSessionProviderId, clearSessionMessages, updateSdkSessionId, updateSessionPermissionProfile } from '@/lib/db';
import { autoApprovePendingForSession } from '@/lib/bridge/permission-broker';
export async function GET(
_request: NextRequest,
@@ -49,6 +50,21 @@ export async function PATCH(
if (body.sdk_session_id !== undefined) {
updateSdkSessionId(id, body.sdk_session_id);
}
if (body.permission_profile !== undefined) {
if (body.permission_profile !== 'default' && body.permission_profile !== 'full_access') {
return Response.json({ error: 'permission_profile must be "default" or "full_access"' }, { status: 400 });
}
// When switching to full_access, auto-approve any pending bridge permissions
const previousProfile = session.permission_profile || 'default';
updateSessionPermissionProfile(id, body.permission_profile);
if (previousProfile !== 'full_access' && body.permission_profile === 'full_access') {
try {
autoApprovePendingForSession(id);
} catch (err) {
console.warn('[session-api] Failed to auto-approve pending permissions:', err);
}
}
}
if (body.clear_messages) {
clearSessionMessages(id);
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getAllProviders, getDefaultProviderId } from '@/lib/db';
import { getContextWindow } from '@/lib/model-context';
import type { ErrorResponse, ProviderModelGroup } from '@/types';
// Default Claude model options
@@ -89,7 +90,10 @@ export async function GET() {
provider_id: 'env',
provider_name: 'Claude Code',
provider_type: 'anthropic',
models: DEFAULT_MODELS,
models: DEFAULT_MODELS.map(m => {
const cw = getContextWindow(m.value);
return cw != null ? { ...m, contextWindow: cw } : m;
}),
});
// Provider types that are not LLMs (e.g. image generation) — skip in chat model selector
@@ -112,7 +116,10 @@ export async function GET() {
} catch { /* use default */ }
}
const models = deduplicateModels(rawModels);
const models = deduplicateModels(rawModels).map(m => {
const cw = getContextWindow(m.value);
return cw != null ? { ...m, contextWindow: cw } : m;
});
groups.push({
provider_id: provider.id,
+3 -1
View File
@@ -25,6 +25,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const [sessionModel, setSessionModel] = useState<string>('');
const [sessionProviderId, setSessionProviderId] = useState<string>('');
const [sessionMode, setSessionMode] = useState<string>('');
const [sessionPermissionProfile, setSessionPermissionProfile] = useState<'default' | 'full_access'>('default');
const [projectName, setProjectName] = useState<string>('');
const [sessionWorkingDir, setSessionWorkingDir] = useState<string>('');
const [isEditingTitle, setIsEditingTitle] = useState(false);
@@ -101,6 +102,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
setSessionModel(data.session.model || '');
setSessionProviderId(data.session.provider_id || '');
setSessionMode(data.session.mode || 'code');
setSessionPermissionProfile(data.session.permission_profile || 'default');
setProjectName(data.session.project_name || '');
}
} catch {
@@ -239,7 +241,7 @@ export default function ChatSessionPage({ params }: ChatSessionPageProps) {
)}
</div>
)}
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} providerId={sessionProviderId} />
<ChatView key={id} sessionId={id} initialMessages={messages} initialHasMore={hasMore} modelName={sessionModel} initialMode={sessionMode} providerId={sessionProviderId} initialPermissionProfile={sessionPermissionProfile} />
</div>
);
}
+5
View File
@@ -5,6 +5,8 @@ import { useRouter } from 'next/navigation';
import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types';
import { MessageList } from '@/components/chat/MessageList';
import { MessageInput } from '@/components/chat/MessageInput';
import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar';
import { ImageGenToggle } from '@/components/chat/ImageGenToggle';
import { PermissionPrompt } from '@/components/chat/PermissionPrompt';
import { usePanel } from '@/hooks/usePanel';
@@ -384,6 +386,9 @@ export default function NewChatPage() {
mode={mode}
onModeChange={setMode}
/>
<ChatComposerActionBar
left={<ImageGenToggle />}
/>
</div>
);
}
@@ -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>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useMemo } from 'react';
import type { Message } from '@/types';
import { getContextWindow } from '@/lib/model-context';
export interface ContextUsageData {
modelName: string;
contextWindow: number | null;
used: number;
ratio: number;
cacheReadTokens: number;
cacheCreationTokens: number;
outputTokens: number;
hasData: boolean;
}
export function useContextUsage(messages: Message[], modelName: string): ContextUsageData {
return useMemo(() => {
const contextWindow = getContextWindow(modelName);
const noData: ContextUsageData = {
modelName,
contextWindow,
used: 0,
ratio: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
outputTokens: 0,
hasData: false,
};
// Find the last assistant message with token_usage
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== 'assistant' || !msg.token_usage) continue;
try {
const usage = typeof msg.token_usage === 'string'
? JSON.parse(msg.token_usage)
: msg.token_usage;
const inputTokens = usage.input_tokens || 0;
const cacheRead = usage.cache_read_input_tokens || 0;
const cacheCreation = usage.cache_creation_input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const used = inputTokens + cacheRead + cacheCreation;
return {
modelName,
contextWindow,
used,
ratio: contextWindow ? used / contextWindow : 0,
cacheReadTokens: cacheRead,
cacheCreationTokens: cacheCreation,
outputTokens,
hasData: true,
};
} catch {
continue;
}
}
return noData;
}, [messages, modelName]);
}
+25
View File
@@ -741,6 +741,31 @@ const en = {
'assistant.confirmNotFoundDesc': 'This path does not exist yet. Create the directory and initialize it as a new assistant workspace?',
'assistant.confirmCreate': 'Create & Initialize',
'assistant.workspaceInvalid': 'Configured workspace path is invalid',
// ── Composer ──────────────────────────────────────────────
'composer.slashCommand': 'Commands',
'composer.slashCommandTooltip': 'Insert slash command',
'composer.designAgent': 'Design Agent',
'composer.designAgentTooltip': 'Enable AI design & image generation',
// ── Permission ────────────────────────────────────────────
'permission.default': 'Default',
'permission.fullAccess': 'Full Access',
'permission.fullAccessWarning': 'Full Access mode will automatically approve all permission requests without confirmation. This includes file writes, shell commands, and network requests. Only enable this if you fully trust the current task.',
'permission.sessionPermission': 'Chat Permission',
// ── Context Usage ─────────────────────────────────────────
'context.usage': 'Context Usage',
'context.model': 'Model',
'context.used': 'Used',
'context.total': 'Total',
'context.percentage': 'Usage',
'context.cacheRead': 'Cache Read',
'context.cacheCreation': 'Cache Creation',
'context.outputTokens': 'Output Tokens',
'context.estimate': 'Estimated from the most recent response',
'context.noData': 'No context usage data yet',
'context.unknown': 'Unknown',
} as const;
export type TranslationKey = keyof typeof en;
+25
View File
@@ -738,6 +738,31 @@ const zh: Record<TranslationKey, string> = {
'assistant.confirmNotFoundDesc': '此路径尚不存在,是否创建目录并初始化为新的助理工作区?',
'assistant.confirmCreate': '创建并初始化',
'assistant.workspaceInvalid': '已配置的工作区路径无效',
// ── Composer ──────────────────────────────────────────────
'composer.slashCommand': '命令',
'composer.slashCommandTooltip': '插入斜杠命令',
'composer.designAgent': '设计 Agent',
'composer.designAgentTooltip': '启用 AI 设计与图片生成',
// ── Permission ────────────────────────────────────────────
'permission.default': '默认权限',
'permission.fullAccess': '完全访问',
'permission.fullAccessWarning': '完全访问模式将自动批准此对话的所有权限请求,包括文件写入、命令执行等潜在危险操作。确定要开启吗?',
'permission.sessionPermission': '对话权限',
// ── Context Usage ─────────────────────────────────────────
'context.usage': '上下文占用',
'context.model': '模型',
'context.used': '已用',
'context.total': '总计',
'context.percentage': '占用',
'context.cacheRead': '缓存读取',
'context.cacheCreation': '缓存创建',
'context.outputTokens': '输出 Token',
'context.estimate': '基于最近一轮响应估算',
'context.noData': '暂无上下文统计',
'context.unknown': '未知上下文窗口',
};
export default zh;
+4
View File
@@ -150,6 +150,9 @@ export async function processMessage(
default: permissionMode = 'acceptEdits'; break;
}
// Bypass permissions entirely when session has full_access profile
const bypassPermissions = session?.permission_profile === 'full_access';
// Load conversation history for context
const { messages: recentMsgs } = getMessages(sessionId, { limit: 50 });
const historyMsgs = recentMsgs.slice(0, -1).map(m => ({
@@ -178,6 +181,7 @@ export async function processMessage(
provider: resolvedProvider,
conversationHistory: historyMsgs,
files,
bypassPermissions,
onRuntimeStatusChange: (status: string) => {
try { setSessionRuntimeStatus(sessionId, status); } catch { /* best effort */ }
},
+42 -1
View File
@@ -13,7 +13,7 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { ChannelAddress, OutboundMessage } from './types';
import type { BaseChannelAdapter } from './channel-adapter';
import { deliver } from './delivery-layer';
import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved } from '../db';
import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved, getSession, getDb } from '../db';
import { resolvePendingPermission } from '../permission-registry';
import { escapeHtml } from './adapters/telegram-utils';
@@ -35,6 +35,16 @@ export async function forwardPermissionRequest(
sessionId?: string,
suggestions?: unknown[],
): Promise<void> {
// Check if this session uses full_access permission profile — auto-approve without IM notification
if (sessionId) {
const session = getSession(sessionId);
if (session?.permission_profile === 'full_access') {
console.log(`[bridge] Auto-approved permission ${permissionRequestId} (tool=${toolName}) due to full_access profile`);
resolvePendingPermission(permissionRequestId, { behavior: 'allow' });
return;
}
}
// Dedup: prevent duplicate forwarding of the same permission request
const now = Date.now();
if (recentPermissionForwards.has(permissionRequestId)) {
@@ -193,3 +203,34 @@ export function handlePermissionCallback(
return resolved;
}
/**
* Auto-approve all pending permission requests for a session.
* Called when a session switches from 'default' to 'full_access' profile.
* Resolves in-memory pending permissions and marks DB links as resolved.
*/
export function autoApprovePendingForSession(sessionId: string): number {
// The permission_requests DB table tracks pending permissions by session_id.
// Find all pending ones and resolve them via the in-memory registry.
const db = getDb();
const pendingRows = db.prepare(
"SELECT id FROM permission_requests WHERE session_id = ? AND status = 'pending'"
).all(sessionId) as { id: string }[];
let resolved = 0;
for (const row of pendingRows) {
const ok = resolvePendingPermission(row.id, { behavior: 'allow' });
if (ok) {
resolved++;
console.log(`[bridge] Auto-approved pending permission ${row.id} for session ${sessionId} (profile switched to full_access)`);
}
// Also mark the IM link as resolved so the button becomes inoperative
try { markPermissionLinkResolved(row.id); } catch { /* best effort */ }
}
if (resolved > 0) {
console.log(`[bridge] Auto-approved ${resolved} pending permission(s) for session ${sessionId}`);
}
return resolved;
}
+5 -3
View File
@@ -31,7 +31,7 @@ import path from 'path';
* Removes null bytes and control characters that cause spawn EINVAL.
*/
function sanitizeEnvValue(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
@@ -280,6 +280,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
conversationHistory,
onRuntimeStatusChange,
imageAgentMode,
bypassPermissions: sessionBypassPermissions,
} = options;
return new ReadableStream<string>({
@@ -363,8 +364,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
}
}
// Check if dangerously_skip_permissions is enabled in app settings
const skipPermissions = getSetting('dangerously_skip_permissions') === 'true';
// Check if dangerously_skip_permissions is enabled globally or per-session
const globalSkip = getSetting('dangerously_skip_permissions') === 'true';
const skipPermissions = globalSkip || !!sessionBypassPermissions;
const queryOptions: Options = {
cwd: workingDirectory || os.homedir(),
+8
View File
@@ -331,6 +331,9 @@ function migrateDb(db: Database.Database): void {
if (!colNames.includes('runtime_error')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN runtime_error TEXT NOT NULL DEFAULT ''");
}
if (!colNames.includes('permission_profile')) {
db.exec("ALTER TABLE chat_sessions ADD COLUMN permission_profile TEXT NOT NULL DEFAULT 'default'");
}
db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_runtime_status ON chat_sessions(runtime_status)");
// Migrate is_active provider to default_provider_id setting
@@ -764,6 +767,11 @@ export function updateSessionMode(id: string, mode: string): void {
db.prepare('UPDATE chat_sessions SET mode = ? WHERE id = ?').run(mode, id);
}
export function updateSessionPermissionProfile(id: string, profile: string): void {
const db = getDb();
db.prepare('UPDATE chat_sessions SET permission_profile = ? WHERE id = ?').run(profile, id);
}
// ==========================================
// Message Operations
// ==========================================
+14
View File
@@ -0,0 +1,14 @@
export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'sonnet': 200000,
'opus': 200000,
'haiku': 200000,
'claude-sonnet-4-20250514': 200000,
'claude-opus-4-20250514': 200000,
'claude-haiku-4-5-20251001': 200000,
};
export function getContextWindow(model: string): number | null {
return MODEL_CONTEXT_WINDOWS[model]
?? MODEL_CONTEXT_WINDOWS[Object.keys(MODEL_CONTEXT_WINDOWS).find(k => model.includes(k)) ?? '']
?? null;
}
+4 -1
View File
@@ -21,6 +21,7 @@ export interface ChatSession {
runtime_status: string;
runtime_updated_at: string;
runtime_error: string;
permission_profile?: 'default' | 'full_access';
}
// ==========================================
@@ -122,7 +123,7 @@ export interface ProviderModelGroup {
provider_id: string; // provider DB id, or 'env' for environment variables
provider_name: string;
provider_type: string;
models: Array<{ value: string; label: string }>;
models: Array<{ value: string; label: string; contextWindow?: number }>;
}
export interface CreateProviderRequest {
@@ -813,4 +814,6 @@ export interface ClaudeStreamOptions {
/** Recent conversation history from DB — used as fallback context when SDK resume is unavailable or fails */
conversationHistory?: Array<{ role: 'user' | 'assistant'; content: string }>;
onRuntimeStatusChange?: (status: string) => void;
/** Per-session bypass: when true, skip all permission checks for this session */
bypassPermissions?: boolean;
}