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

+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;
}