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:
1 parent
111fa8c92e
commit
6c02c135fe
9 files changed
+52
-29
No files matched your search
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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
@@ -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)!;
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ export interface CreateSessionRequest {
|
||||
working_directory?: string;
|
||||
mode?: string;
|
||||
provider_id?: string;
|
||||
permission_profile?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
||||
Reference in new issue
Block a user