fix: composer UI polish and permission selector improvements

Slash command:
- Fix first-click bug: set textarea.value and cursor position before
  calling handleInputChange so selectionStart reads correctly
- Move slash button to right of + (attach) button
- Simplify icon from CommandLineIcon to plain "/" text

Composer action bar:
- Move Agent toggle and permission selector to left side (was centered)
- Adjust spacing: reduce input padding, increase bottom margin

Permission selector:
- Add dropdown arrow indicator (ArrowDown01Icon)
- Change full_access color from orange to red (icon + text + background)
- Check res.ok before updating UI state (prevent false success)
- Make sessionId optional to support /chat new session page
- Log warnings on PATCH failure instead of silent catch

New chat page (/chat):
- Add ChatPermissionSelector with local state before session exists
- Pass permission_profile in session creation payload
- Backend createSession() now accepts and writes permission_profile

Context usage indicator:
- Reduce circle size from 22px to 16px
- Change used-portion color to dark gray (zinc-600/400)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-07 16:03:19 +08:00
1 parent 111fa8c92e
commit 6c02c135fe
9 files changed
+52 -29

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ export async function POST(request: NextRequest) {
body.working_directory,
body.mode,
body.provider_id,
body.permission_profile,
);
const response: SessionResponse = { session };
return Response.json(response, { status: 201 });
+10 -1
View File
@@ -6,6 +6,7 @@ import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestE
import { MessageList } from '@/components/chat/MessageList';
import { MessageInput } from '@/components/chat/MessageInput';
import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar';
import { ChatPermissionSelector } from '@/components/chat/ChatPermissionSelector';
import { ImageGenToggle } from '@/components/chat/ImageGenToggle';
import { PermissionPrompt } from '@/components/chat/PermissionPrompt';
import { usePanel } from '@/hooks/usePanel';
@@ -37,6 +38,7 @@ export default function NewChatPage() {
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
const [streamingToolOutput, setStreamingToolOutput] = useState('');
const [permissionProfile, setPermissionProfile] = useState<'default' | 'full_access'>('default');
const abortControllerRef = useRef<AbortController | null>(null);
const stopStreaming = useCallback(() => {
@@ -114,6 +116,7 @@ export default function NewChatPage() {
title: content.slice(0, 50),
mode,
working_directory: workingDir.trim(),
permission_profile: permissionProfile,
};
const createRes = await fetch('/api/chat/sessions', {
@@ -315,7 +318,7 @@ export default function NewChatPage() {
abortControllerRef.current = null;
}
},
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, setPendingApprovalSessionId]
[isStreaming, router, workingDir, mode, currentModel, currentProviderId, permissionProfile, setPendingApprovalSessionId]
);
const handleCommand = useCallback((command: string) => {
@@ -388,6 +391,12 @@ export default function NewChatPage() {
/>
<ChatComposerActionBar
left={<ImageGenToggle />}
center={
<ChatPermissionSelector
permissionProfile={permissionProfile}
onPermissionChange={setPermissionProfile}
/>
}
/>
</div>
);
@@ -10,11 +10,9 @@ interface ChatComposerActionBarProps {
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 justify-between gap-2 px-4 pt-0.5 pb-2.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">
+21 -8
View File
@@ -19,10 +19,10 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { HugeiconsIcon } from '@hugeicons/react';
import { LockIcon, SquareUnlock02Icon } from '@hugeicons/core-free-icons';
import { LockIcon, SquareUnlock02Icon, ArrowDown01Icon } from '@hugeicons/core-free-icons';
interface ChatPermissionSelectorProps {
sessionId: string;
sessionId?: string;
permissionProfile: 'default' | 'full_access';
onPermissionChange: (profile: 'default' | 'full_access') => void;
}
@@ -44,15 +44,24 @@ export function ChatPermissionSelector({
};
const applyChange = async (profile: 'default' | 'full_access') => {
// No sessionId yet (new chat) — local-only update
if (!sessionId) {
onPermissionChange(profile);
return;
}
try {
await fetch(`/api/chat/sessions/${sessionId}`, {
const res = await fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ permission_profile: profile }),
});
if (!res.ok) {
console.warn(`[ChatPermissionSelector] PATCH failed: ${res.status}`);
return;
}
onPermissionChange(profile);
} catch {
// silent
} catch (err) {
console.warn('[ChatPermissionSelector] PATCH error:', err);
}
};
@@ -66,17 +75,21 @@ export function ChatPermissionSelector({
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-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
<HugeiconsIcon
icon={isFullAccess ? SquareUnlock02Icon : LockIcon}
className="h-3.5 w-3.5"
className={`h-3.5 w-3.5 ${isFullAccess ? 'text-red-500' : ''}`}
/>
<span>
{isFullAccess ? t('permission.fullAccess') : t('permission.default')}
</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
className="h-2.5 w-2.5 opacity-60"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[140px]">
@@ -85,7 +98,7 @@ export function ChatPermissionSelector({
<span>{t('permission.default')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSelect('full_access')}>
<HugeiconsIcon icon={SquareUnlock02Icon} className="h-3.5 w-3.5 text-orange-500" />
<HugeiconsIcon icon={SquareUnlock02Icon} className="h-3.5 w-3.5 text-red-500" />
<span>{t('permission.fullAccess')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -23,18 +23,18 @@ export function ContextUsageIndicator({ messages, modelName }: ContextUsageIndic
const { t } = useTranslation();
const usage = useContextUsage(messages, modelName);
const size = 22;
const strokeWidth = 3;
const size = 16;
const strokeWidth = 2.5;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - usage.ratio * circumference;
// Color based on usage ratio
// Color based on usage ratio — used portion is dark gray by default
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';
else strokeColor = 'text-zinc-600 dark:text-zinc-400';
}
return (
+10 -8
View File
@@ -620,13 +620,14 @@ export function MessageInput({
const before = inputValue.slice(0, cursorPos);
const after = inputValue.slice(cursorPos);
const newValue = before + '/' + after;
const newCursorPos = cursorPos + 1;
setInputValue(newValue);
// Set cursor position first so handleInputChange reads correct selectionStart
textarea.value = newValue;
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
textarea.focus();
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>) => {
@@ -935,7 +936,7 @@ export function MessageInput({
const chatStatus: ChatStatus = isStreaming ? 'streaming' : 'ready';
return (
<div className="bg-background/80 backdrop-blur-lg px-4 py-3">
<div className="bg-background/80 backdrop-blur-lg px-4 pt-2 pb-1">
<div className="mx-auto">
<div className="relative">
{/* Popover */}
@@ -1136,6 +1137,9 @@ export function MessageInput({
{/* Attach file button */}
<AttachFileButton />
{/* Slash command button */}
<SlashCommandButton onInsertSlash={handleInsertSlash} />
{/* Model selector */}
<div className="relative" ref={modelMenuRef}>
<PromptInputButton
@@ -1187,8 +1191,6 @@ export function MessageInput({
)}
</div>
{/* Slash command button */}
<SlashCommandButton onInsertSlash={handleInsertSlash} />
</PromptInputTools>
<FileAwareSubmitButton
+1 -3
View File
@@ -1,7 +1,5 @@
'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 {
@@ -22,7 +20,7 @@ export function SlashCommandButton({ onInsertSlash }: SlashCommandButtonProps) {
<Tooltip>
<TooltipTrigger asChild>
<PromptInputButton onClick={onInsertSlash}>
<HugeiconsIcon icon={CommandLineIcon} className="h-3.5 w-3.5" />
<span className="text-sm font-medium leading-none">/</span>
</PromptInputButton>
</TooltipTrigger>
<TooltipContent>
+3 -2
View File
@@ -690,6 +690,7 @@ export function createSession(
workingDirectory?: string,
mode?: string,
providerId?: string,
permissionProfile?: string,
): ChatSession {
const db = getDb();
const id = crypto.randomBytes(16).toString('hex');
@@ -698,8 +699,8 @@ export function createSession(
const projectName = path.basename(wd);
db.prepare(
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode, sdk_cwd, provider_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code', wd, providerId || '');
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode, sdk_cwd, provider_id, permission_profile) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code', wd, providerId || '', permissionProfile || 'default');
return getSession(id)!;
}
+1
View File
@@ -176,6 +176,7 @@ export interface CreateSessionRequest {
working_directory?: string;
mode?: string;
provider_id?: string;
permission_profile?: string;
}
export interface SendMessageRequest {