mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* style(web): polish sidebar and tool typography - Use UI font and medium weight for sidebar and composer controls - Add reusable shortcut and tool output blocks - Cap long tool output at 50 lines with a scrollbar - Update sidebar show-more copy and muted styling * style(web): refine workspace picker sizing * style(web): align composer mode menus * style(web): tune list and question typography * style(web): reuse shortcut keys in approvals * style(web): size workspace picker from content * feat(web): localize chat status labels * style(web): refine composer toolbar controls * style(web): use complete Inter variable font * style(web): tune sidebar workspace typography * style(web): polish composer and workspace picker * style(web): refine markdown and thinking typography * chore: add web UI polish changeset * fix(web): pin Inter package for Nix build * style(web): polish goal tool calls * style: polish goal mode display * fix: layer latest message pill below menus * style: align goal strip content
2290 lines
71 KiB
Vue
2290 lines
71 KiB
Vue
<!-- apps/kimi-web/src/components/chat/Composer.vue -->
|
||
<script setup lang="ts">
|
||
import { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
import SlashMenu from './SlashMenu.vue';
|
||
import MentionMenu from './MentionMenu.vue';
|
||
import { buildSlashItems, parseSlash, SKILL_COMMAND_PREFIX } from '../../lib/slashCommands';
|
||
import type { FileItem } from './MentionMenu.vue';
|
||
import type { ActivationBadges, ConversationStatus, PermissionMode, QueuedPromptView } from '../../types';
|
||
import type { AppGoal, AppModel, AppSkill, ThinkingLevel } from '../../api/types';
|
||
import {
|
||
coerceThinkingForModel,
|
||
commitLevel,
|
||
effortLabel,
|
||
isThinkingOn,
|
||
modelThinkingAvailability,
|
||
segmentsFor,
|
||
} from '../../lib/modelThinking';
|
||
import { useInputHistory } from '../../composables/useInputHistory';
|
||
import { useSlashMenu } from '../../composables/useSlashMenu';
|
||
import { useMentionMenu } from '../../composables/useMentionMenu';
|
||
import { useComposerDraft } from '../../composables/useComposerDraft';
|
||
import { useAttachmentUpload } from '../../composables/useAttachmentUpload';
|
||
import Spinner from '../ui/Spinner.vue';
|
||
import Button from '../ui/Button.vue';
|
||
import IconButton from '../ui/IconButton.vue';
|
||
import Icon from '../ui/Icon.vue';
|
||
import ContextRing from '../ui/ContextRing.vue';
|
||
import Tooltip from '../ui/Tooltip.vue';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Props & emits
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const props = withDefaults(defineProps<{
|
||
running?: boolean;
|
||
/** Active session id — scopes the persisted unsent draft (per session). */
|
||
sessionId?: string;
|
||
queued?: QueuedPromptView[];
|
||
searchFiles?: (q: string) => Promise<FileItem[]>;
|
||
/** If undefined, attach button is hidden and paste/drag are no-ops. */
|
||
uploadImage?: (file: Blob, name?: string) => Promise<{ fileId: string; name: string; mediaType: string } | null>;
|
||
/** Status data (model, context, permission) — drives the bottom toolbar. */
|
||
status?: ConversationStatus;
|
||
thinking?: ThinkingLevel;
|
||
planMode?: boolean;
|
||
swarmMode?: boolean;
|
||
goalMode?: boolean;
|
||
goal?: AppGoal | null;
|
||
activationBadges?: ActivationBadges;
|
||
/** Available models for the quick-switch dropdown. */
|
||
models?: AppModel[];
|
||
/** Starred model ids shown at the top of the quick-switch dropdown. */
|
||
starredIds?: string[];
|
||
/** Session skills shown in the `/` menu (after the built-in commands). */
|
||
skills?: AppSkill[];
|
||
/** Hide the context-usage indicator (used on the empty-session landing page). */
|
||
hideContext?: boolean;
|
||
}>(), {
|
||
running: false,
|
||
queued: () => [],
|
||
searchFiles: undefined,
|
||
uploadImage: undefined,
|
||
models: () => [],
|
||
starredIds: () => [],
|
||
skills: () => [],
|
||
});
|
||
|
||
const placeholder = computed(() =>
|
||
props.running
|
||
? t('composer.placeholderRunning')
|
||
: props.goalMode
|
||
? t('status.goalPlaceholder')
|
||
: t('composer.placeholder')
|
||
);
|
||
|
||
const emit = defineEmits<{
|
||
submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
|
||
/** Steer the composer text (+ any queued prompts, merged by the parent)
|
||
into the RUNNING turn — TUI ctrl+s. */
|
||
steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
|
||
command: [cmd: string];
|
||
interrupt: [];
|
||
setPermission: [mode: PermissionMode];
|
||
setThinking: [level: ThinkingLevel];
|
||
togglePlan: [];
|
||
toggleSwarm: [];
|
||
toggleGoal: [];
|
||
openBtw: [];
|
||
createGoal: [objective: string];
|
||
controlGoal: [action: 'pause' | 'resume' | 'cancel'];
|
||
focusGoal: [];
|
||
focusSwarm: [];
|
||
compact: [];
|
||
pickModel: [];
|
||
selectModel: [modelId: string];
|
||
}>();
|
||
|
||
const { t, locale } = useI18n();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Textarea + per-session draft persistence — see useComposerDraft.
|
||
// ---------------------------------------------------------------------------
|
||
const { text, textareaRef, autosize, loadForEdit, clearDraft } = useComposerDraft({
|
||
sessionId: () => props.sessionId,
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Expanded editor — a taller, multi-line composing mode. While expanded, Enter
|
||
// inserts a newline instead of sending (send via the button or Cmd/Ctrl+Enter);
|
||
// it auto-collapses after a successful send. See handleKeydown / handleSubmit.
|
||
// ---------------------------------------------------------------------------
|
||
const expanded = ref(false);
|
||
function toggleExpand(): void {
|
||
expanded.value = !expanded.value;
|
||
// Re-fit the textarea after the min/max-height swap between modes, then
|
||
// recompute growth against the *post-toggle* resting height. Without this,
|
||
// collapsing would keep the isGrown measured against the expanded 70vh
|
||
// min-height, hiding the toggle even though the collapsed draft is still
|
||
// multi-line. (This does not affect the expanded state itself — once
|
||
// expanded, it stays at 70vh until toggled back or sent.)
|
||
void nextTick(() => {
|
||
autosize();
|
||
recomputeGrown();
|
||
// Return focus to the textarea so the user can keep typing right away;
|
||
// otherwise focus stays on the toggle button and the next Enter would
|
||
// activate it again instead of inserting a newline.
|
||
textareaRef.value?.focus();
|
||
});
|
||
}
|
||
|
||
// Collapse the expanded editor after a successful send/steer and re-fit the
|
||
// textarea once the 70vh min-height is gone. On image-only sends the text is
|
||
// already empty, so the draft watcher never re-runs autosize — without this,
|
||
// the textarea keeps the inline height measured at 70vh and the collapsed cap
|
||
// (1/4 viewport) leaves an oversized empty box until the next keystroke.
|
||
function collapseAndRefit(): void {
|
||
if (!expanded.value) return;
|
||
expanded.value = false;
|
||
void nextTick(autosize);
|
||
}
|
||
|
||
// The expand toggle is hidden at the resting height and only appears once the
|
||
// box has grown past it (multi-line content) — keeps the empty composer
|
||
// uncluttered. While expanded it always shows so the user can collapse back.
|
||
//
|
||
// The resting height equals the textarea's computed `min-height` (set in
|
||
// style.css). We read it from the element instead of hard-coding.
|
||
const RESTING_HEIGHT_FALLBACK_PX = 36;
|
||
function restingHeightPx(el: HTMLTextAreaElement): number {
|
||
if (typeof getComputedStyle === 'undefined') return RESTING_HEIGHT_FALLBACK_PX;
|
||
const min = Number.parseFloat(getComputedStyle(el).minHeight);
|
||
return Number.isFinite(min) && min > 0 ? min : RESTING_HEIGHT_FALLBACK_PX;
|
||
}
|
||
const isGrown = ref(false);
|
||
function recomputeGrown(): void {
|
||
const el = textareaRef.value;
|
||
isGrown.value = !!el && el.scrollHeight > restingHeightPx(el);
|
||
}
|
||
watch(text, () => {
|
||
// Registered after useComposerDraft's autosize watcher, so the inline height
|
||
// already reflects the latest content when this reads scrollHeight.
|
||
void nextTick(recomputeGrown);
|
||
});
|
||
|
||
// The component instance is reused across session switches (it is not keyed by
|
||
// session), so reset the per-session expanded preference when the active
|
||
// session changes. Without this, expanding in one chat would leave the next
|
||
// session's draft stuck in the tall editor with Enter inserting newlines.
|
||
watch(() => props.sessionId, () => {
|
||
expanded.value = false;
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sent-message history recall (shell-style ↑/↓). See useInputHistory for the
|
||
// implementation; the composer keeps the keydown orchestration (which also
|
||
// juggles the slash and mention menus).
|
||
// ---------------------------------------------------------------------------
|
||
const history = useInputHistory({ text, textareaRef, autosize, sessionId: () => props.sessionId });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Slash-command menu — see useSlashMenu for the implementation. The composer
|
||
// keeps the keydown orchestration (arrow keys / Enter / Escape) because it also
|
||
// juggles the mention menu and history recall.
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
open: slashOpen,
|
||
items: slashItems,
|
||
active: slashActive,
|
||
update: updateSlashMenu,
|
||
select: selectSlashCommand,
|
||
} = useSlashMenu({
|
||
text,
|
||
textareaRef,
|
||
autosize,
|
||
skills: () => props.skills,
|
||
emitCommand: (cmd) => emit('command', cmd),
|
||
historyPush: (entry) => history.push(entry),
|
||
clearDraft,
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// @-mention menu — see useMentionMenu for the implementation. The composer
|
||
// keeps the keydown orchestration because it also juggles the slash menu and
|
||
// history recall.
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
open: mentionOpen,
|
||
items: mentionItems,
|
||
active: mentionActive,
|
||
loading: mentionLoading,
|
||
update: updateMentionMenu,
|
||
select: selectMentionItem,
|
||
} = useMentionMenu({
|
||
text,
|
||
textareaRef,
|
||
autosize,
|
||
searchFiles: () => props.searchFiles,
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Input event handler — updates both menus
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleInput(): void {
|
||
// Manual typing leaves history-browsing mode — the text is now a fresh draft.
|
||
history.resetBrowsing();
|
||
updateSlashMenu();
|
||
updateMentionMenu();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Attachments — see useAttachmentUpload. The composer keeps handleSubmit /
|
||
// handleSteer (which read the attachments to build the payload) and the
|
||
// `hasUpload` toolbar flag.
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
attachments,
|
||
previewAttachment,
|
||
fileInputRef,
|
||
isDragOver,
|
||
removeAttachment,
|
||
openAttachmentPreview,
|
||
closeAttachmentPreview,
|
||
openFilePicker,
|
||
handleFileInputChange,
|
||
handleDragOver,
|
||
handleDragLeave,
|
||
handleDrop,
|
||
clearAfterSubmit,
|
||
loadAttachments,
|
||
} = useAttachmentUpload({ uploadImage: () => props.uploadImage, sessionId: () => props.sessionId });
|
||
|
||
// Silence noUnusedLocals: fileInputRef is used as a template ref (ref="fileInputRef").
|
||
void fileInputRef;
|
||
|
||
onMounted(() => {
|
||
// Fit the box to a restored draft on first render, and reflect its grown
|
||
// state so the expand toggle shows for an already-long draft.
|
||
if (text.value) {
|
||
void nextTick(() => {
|
||
autosize();
|
||
recomputeGrown();
|
||
});
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('mousedown', onModesDocClick);
|
||
clearCompositionEndTimer();
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Submit / keydown
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// loadForEdit comes from useComposerDraft (it lives next to the text state).
|
||
function focus(): void {
|
||
// preventScroll keeps the pane from jumping if the composer is already in view
|
||
// or if focus is triggered during an animation/transition.
|
||
textareaRef.value?.focus({ preventScroll: true });
|
||
}
|
||
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
|
||
loadAttachments(atts);
|
||
}
|
||
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
|
||
|
||
function handleSubmit(): void {
|
||
const trimmed = text.value.trim();
|
||
|
||
// An upload is still in flight — submitting now would silently send the
|
||
// message WITHOUT the image. Keep the text + chips (the chip shows its
|
||
// uploading spinner); the user submits again in a moment.
|
||
if (attachments.value.some((a) => a.uploading)) return;
|
||
|
||
// Allow submission with images even when text is empty
|
||
const readyAttachments = attachments.value.filter((a) => !a.uploading && !a.error && a.fileId);
|
||
|
||
if (!trimmed && readyAttachments.length === 0) return;
|
||
|
||
// Record for ↑/↓ recall before the slash branch so commands (with or without
|
||
// args) are recallable too, not just plain messages. `push` ignores empty /
|
||
// whitespace, so an image-only send adds nothing.
|
||
history.push(trimmed);
|
||
|
||
// If it's a known slash command, keep the optional tail as command input
|
||
// instead of submitting it as normal chat text. This covers `/goal <task>`,
|
||
// `/swarm <task>`, `/btw <question>`, slash skills with args, and bare
|
||
// commands such as `/model`. A hand-typed bare skill name (`/deploy`) also
|
||
// resolves to its prefixed menu entry (`/skill:deploy`), mirroring the TUI.
|
||
if (trimmed) {
|
||
const parsed = parseSlash(trimmed);
|
||
const known = parsed
|
||
? buildSlashItems(props.skills).some(
|
||
(item) => item.name === parsed.cmd || item.name === `/${SKILL_COMMAND_PREFIX}${parsed.cmd.slice(1)}`,
|
||
)
|
||
: false;
|
||
if (parsed && known) {
|
||
text.value = '';
|
||
clearDraft();
|
||
slashOpen.value = false;
|
||
collapseAndRefit();
|
||
emit('command', parsed.arg ? `${parsed.cmd} ${parsed.arg}` : parsed.cmd);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
text: trimmed,
|
||
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
|
||
};
|
||
|
||
// Revoke object URLs and drop the submitted attachments.
|
||
previewAttachment.value = null;
|
||
clearAfterSubmit();
|
||
|
||
text.value = '';
|
||
clearDraft();
|
||
slashOpen.value = false;
|
||
mentionOpen.value = false;
|
||
collapseAndRefit();
|
||
emit('submit', payload);
|
||
}
|
||
|
||
/**
|
||
* Steer (TUI ctrl+s): push the current text — and the parent merges any queued
|
||
* prompts — straight into the running turn. With an empty composer it still
|
||
* fires when something is queued, so "queue a few thoughts, then ctrl+s" works.
|
||
*/
|
||
function handleSteer(): void {
|
||
if (!props.running) return;
|
||
if (attachments.value.some((a) => a.uploading)) return;
|
||
|
||
const trimmed = text.value.trim();
|
||
const readyAttachments = attachments.value.filter((a) => !a.uploading && !a.error && a.fileId);
|
||
if (!trimmed && readyAttachments.length === 0 && props.queued.length === 0) return;
|
||
|
||
const payload = {
|
||
text: trimmed,
|
||
attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })),
|
||
};
|
||
clearAfterSubmit();
|
||
history.push(trimmed);
|
||
text.value = '';
|
||
clearDraft();
|
||
slashOpen.value = false;
|
||
mentionOpen.value = false;
|
||
collapseAndRefit();
|
||
emit('steer', payload);
|
||
}
|
||
|
||
let isComposingText = false;
|
||
let compositionEndTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function clearCompositionEndTimer(): void {
|
||
if (compositionEndTimer !== null) {
|
||
clearTimeout(compositionEndTimer);
|
||
compositionEndTimer = null;
|
||
}
|
||
}
|
||
|
||
function handleCompositionStart(): void {
|
||
clearCompositionEndTimer();
|
||
isComposingText = true;
|
||
}
|
||
|
||
function handleCompositionEnd(): void {
|
||
clearCompositionEndTimer();
|
||
compositionEndTimer = setTimeout(() => {
|
||
compositionEndTimer = null;
|
||
isComposingText = false;
|
||
}, 0);
|
||
}
|
||
|
||
function isComposingKeyEvent(e: KeyboardEvent): boolean {
|
||
return isComposingText || e.isComposing || e.keyCode === 229;
|
||
}
|
||
|
||
function handleKeydown(e: KeyboardEvent): void {
|
||
if (isComposingKeyEvent(e)) return;
|
||
|
||
// Close dropdowns on Escape
|
||
if (e.key === 'Escape') {
|
||
if (dropdownOpen.value) {
|
||
e.preventDefault();
|
||
closeDropdown();
|
||
return;
|
||
}
|
||
if (permDropdownOpen.value) {
|
||
e.preventDefault();
|
||
closePermDropdown();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Slash menu navigation
|
||
if (slashOpen.value) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
slashActive.value = (slashActive.value + 1) % slashItems.value.length;
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
slashActive.value = (slashActive.value - 1 + slashItems.value.length) % slashItems.value.length;
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const item = slashItems.value[slashActive.value];
|
||
if (item) selectSlashCommand(item);
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
slashOpen.value = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Mention menu navigation
|
||
if (mentionOpen.value && !mentionLoading.value) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
mentionActive.value = (mentionActive.value + 1) % Math.max(1, mentionItems.value.length);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
mentionActive.value = (mentionActive.value - 1 + Math.max(1, mentionItems.value.length)) % Math.max(1, mentionItems.value.length);
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||
e.preventDefault();
|
||
const item = mentionItems.value[mentionActive.value];
|
||
if (item) selectMentionItem(item);
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
mentionOpen.value = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Ctrl+S / Cmd+S — steer into the running turn (TUI parity)
|
||
if (e.key === 's' && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
||
if (props.running) {
|
||
e.preventDefault();
|
||
handleSteer();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// History recall (shell-style ↑/↓) — see useInputHistory for the machinery.
|
||
//
|
||
// Disabled entirely in the expanded editor: that mode is for composing long
|
||
// multi-line text, so the arrows always move the caret within the draft and
|
||
// never jump to a previous message.
|
||
//
|
||
// ENTERING history: a plain ArrowUp only recalls when the caret is at the
|
||
// very start of the text, so editing a multi-line draft with the arrows
|
||
// still works — ArrowUp moves the caret within the draft until it reaches
|
||
// the top, instead of jumping to a previous message mid-navigation.
|
||
// ONCE BROWSING, the arrows walk history directly, regardless of where the
|
||
// caret landed — a recalled multi-line entry leaves the caret at its end, and
|
||
// the old "must be at the start" gate then trapped it there, so further
|
||
// ArrowUp did nothing ("only one step back"). Walking freely while browsing
|
||
// fixes that; typing exits history (handleInput resets browsing), after which
|
||
// the arrows move the caret normally again.
|
||
if (!expanded.value && !slashOpen.value && !mentionOpen.value && !e.shiftKey && !e.altKey && !e.metaKey && !e.ctrlKey) {
|
||
const browsing = history.isBrowsing();
|
||
if (e.key === 'ArrowUp' && history.hasHistory() && (browsing || history.caretAtTextStart())) {
|
||
e.preventDefault();
|
||
history.recallOlder();
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowDown' && browsing) {
|
||
e.preventDefault();
|
||
history.recallNewer();
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Normal Enter / Shift+Enter
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
// Expanded editor: Enter inserts a newline; Cmd/Ctrl+Enter sends.
|
||
// (Clicking the send button always sends.) Shift+Enter already falls
|
||
// through to the default newline above, so behavior matches either way.
|
||
if (expanded.value && !(e.metaKey || e.ctrlKey)) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
handleSubmit();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Computed
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Send is always "send" — while running it enqueues (handled upstream by
|
||
// sendPrompt). Interrupt lives on a separate Stop button so the two can never
|
||
// be confused.
|
||
const sendLabel = computed(() => t('composer.send'));
|
||
const hasUpload = computed(() => !!props.uploadImage);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bottom toolbar — split into individual controls
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const dropdownOpen = ref(false);
|
||
const permDropdownOpen = ref(false);
|
||
const toolbarRef = ref<HTMLElement | null>(null);
|
||
|
||
function toggleDropdown(): void {
|
||
dropdownOpen.value = !dropdownOpen.value;
|
||
if (dropdownOpen.value) {
|
||
permDropdownOpen.value = false;
|
||
closeModes();
|
||
document.addEventListener('click', onDocClick, true);
|
||
} else {
|
||
document.removeEventListener('click', onDocClick, true);
|
||
}
|
||
}
|
||
|
||
function closeDropdown(): void {
|
||
dropdownOpen.value = false;
|
||
if (!permDropdownOpen.value) {
|
||
document.removeEventListener('click', onDocClick, true);
|
||
}
|
||
}
|
||
|
||
function togglePermDropdown(): void {
|
||
permDropdownOpen.value = !permDropdownOpen.value;
|
||
if (permDropdownOpen.value) {
|
||
dropdownOpen.value = false;
|
||
closeModes();
|
||
document.addEventListener('click', onDocClick, true);
|
||
} else {
|
||
document.removeEventListener('click', onDocClick, true);
|
||
}
|
||
}
|
||
|
||
function closePermDropdown(): void {
|
||
permDropdownOpen.value = false;
|
||
if (!dropdownOpen.value) {
|
||
document.removeEventListener('click', onDocClick, true);
|
||
}
|
||
}
|
||
|
||
function onDocClick(e: MouseEvent): void {
|
||
if (toolbarRef.value && !toolbarRef.value.contains(e.target as Node)) {
|
||
closeDropdown();
|
||
closePermDropdown();
|
||
}
|
||
}
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('click', onDocClick, true);
|
||
});
|
||
|
||
// Context formatting
|
||
const kFmt = (n: number) => `${Math.round(n / 1000)}k`;
|
||
// Clamped to 0–100: ctxUsed can momentarily exceed ctxMax (estimates), and
|
||
// ctxMax can be 0 before the first status fetch — both broke the ring.
|
||
const pct = computed(() => {
|
||
const max = props.status?.ctxMax ?? 0;
|
||
if (max <= 0) return 0;
|
||
return Math.min(100, Math.max(0, Math.round(((props.status?.ctxUsed ?? 0) / max) * 100)));
|
||
});
|
||
|
||
const ctxTooltip = computed(() => {
|
||
const used = (props.status?.ctxUsed ?? 0).toLocaleString();
|
||
const max = (props.status?.ctxMax ?? 0).toLocaleString();
|
||
return t('status.ctxTooltip', { used, max, pct: pct.value });
|
||
});
|
||
|
||
const showCompact = computed(() => pct.value >= 80);
|
||
|
||
// Thinking toggle
|
||
const currentModel = computed(() => {
|
||
const raw = props.status?.modelId ?? props.status?.model ?? '';
|
||
return props.models?.find((m) =>
|
||
m.id === raw ||
|
||
m.model === raw ||
|
||
m.displayName === props.status?.model,
|
||
);
|
||
});
|
||
const thinkingAvailability = computed(() => modelThinkingAvailability(currentModel.value));
|
||
const thinkingSegments = computed(() => segmentsFor(currentModel.value));
|
||
// The persisted level can be stale relative to the active model (e.g. a
|
||
// boolean 'on'/'off' carried over when selecting another session). Coerce it
|
||
// against the current model before deriving display state so an always-on
|
||
// model never shows "thinking: off" and an effort model shows its concrete
|
||
// level instead of the bare "thinking" tag.
|
||
const coercedThinkingLevel = computed(() =>
|
||
coerceThinkingForModel(currentModel.value, props.thinking ?? 'off'),
|
||
);
|
||
// Runtime level clamped to the segments this model actually offers, so a
|
||
// carried-over value never highlights a segment that doesn't exist here.
|
||
const activeThinkingSegment = computed(() => {
|
||
const segs = thinkingSegments.value;
|
||
const level = coercedThinkingLevel.value;
|
||
if (segs.includes(level)) return level;
|
||
if (segs.includes('on')) return 'on';
|
||
return segs[0] ?? 'off';
|
||
});
|
||
const thinkingOn = computed(() => {
|
||
if (thinkingAvailability.value === 'always-on') return true;
|
||
if (thinkingAvailability.value === 'unsupported') return false;
|
||
return isThinkingOn(coercedThinkingLevel.value);
|
||
});
|
||
// Single-segment (always-on boolean) or unsupported models can't be changed.
|
||
const thinkingReadonly = computed(
|
||
() => thinkingAvailability.value === 'unsupported' || thinkingSegments.value.length <= 1,
|
||
);
|
||
// Footer-style suffix: effort models show the concrete level; boolean models
|
||
// keep the plain "thinking" tag; off shows nothing.
|
||
const thinkingSuffix = computed(() => {
|
||
if (!thinkingOn.value) return '';
|
||
const hasEfforts = (currentModel.value?.supportEfforts?.length ?? 0) > 0;
|
||
const level = coercedThinkingLevel.value;
|
||
if (hasEfforts && level !== 'on') return t('composer.thinkingSuffixEffort', { level });
|
||
return t('composer.thinkingSuffix');
|
||
});
|
||
function setThinkingSegment(draft: string): void {
|
||
if (thinkingReadonly.value) return;
|
||
emit('setThinking', commitLevel(currentModel.value, draft));
|
||
}
|
||
function thinkingSegmentLabel(segment: string): string {
|
||
if (segment === 'on') return t('status.thinkingOn');
|
||
if (segment === 'off') return t('status.thinkingOff');
|
||
return effortLabel(segment);
|
||
}
|
||
|
||
// Plan toggle
|
||
const planOn = computed(() => props.planMode === true);
|
||
const swarmOn = computed(() => props.swarmMode === true);
|
||
const goalStatus = computed(() => props.goal?.status ?? props.activationBadges?.goal?.status ?? null);
|
||
const goalActive = computed(() => goalStatus.value !== null && goalStatus.value !== 'complete');
|
||
const goalArmed = computed(() => goalActive.value || props.goalMode === true);
|
||
const goalCanPause = computed(() => goalStatus.value === 'active');
|
||
const goalCanResume = computed(() => goalStatus.value === 'paused' || goalStatus.value === 'blocked');
|
||
|
||
// Modes selector (plan / goal / swarm) — the popover that replaces the bare
|
||
// "plan" pill. Plan/Swarm are real client toggles; goal reflects agent-driven
|
||
// state and focuses its card when active.
|
||
const modesOpen = ref(false);
|
||
const modesRef = ref<HTMLElement | null>(null);
|
||
const modesMenuRef = ref<HTMLElement | null>(null);
|
||
// The menu is position:fixed (so no composer stacking context can paint over
|
||
// it); these coords anchor it just above the pill, computed on open.
|
||
const modesMenuStyle = ref<Record<string, string>>({});
|
||
const anyModeActive = computed(() => planOn.value || swarmOn.value || goalArmed.value);
|
||
function closeModes(): void {
|
||
modesOpen.value = false;
|
||
document.removeEventListener('mousedown', onModesDocClick);
|
||
}
|
||
function onModesDocClick(e: MouseEvent): void {
|
||
const t = e.target as Node;
|
||
if (modesRef.value?.contains(t) || modesMenuRef.value?.contains(t)) return;
|
||
closeModes();
|
||
}
|
||
function toggleModes(): void {
|
||
if (modesOpen.value) {
|
||
closeModes();
|
||
return;
|
||
}
|
||
// Keep the toolbar menus mutually exclusive so they never overlap.
|
||
closeDropdown();
|
||
closePermDropdown();
|
||
const r = modesRef.value?.getBoundingClientRect();
|
||
if (r) {
|
||
modesMenuStyle.value = {
|
||
left: `${Math.round(r.left)}px`,
|
||
bottom: `${Math.round(window.innerHeight - r.top + 8)}px`,
|
||
};
|
||
}
|
||
modesOpen.value = true;
|
||
setTimeout(() => document.addEventListener('mousedown', onModesDocClick), 0);
|
||
}
|
||
// Permission modes
|
||
const PERM_MODES: { mode: PermissionMode; color: string; labelKey: string; descKey: string }[] = [
|
||
{ mode: 'manual', color: 'var(--dim)', labelKey: 'status.permissionManual', descKey: 'status.permissionManualDesc' },
|
||
{ mode: 'yolo', color: 'var(--color-warning)', labelKey: 'status.permissionYolo', descKey: 'status.permissionYoloDesc' },
|
||
{ mode: 'auto', color: 'var(--color-danger)', labelKey: 'status.permissionAuto', descKey: 'status.permissionAutoDesc' },
|
||
];
|
||
const MODE_DESC_KEYS = ['status.planDesc', 'status.swarmDesc', 'status.goalDesc'] as const;
|
||
|
||
const menuMeasureRef = ref<HTMLElement | null>(null);
|
||
const permissionDescriptionWidth = ref('');
|
||
const modeDescriptionWidth = ref('');
|
||
function menuDescStyle(width: string): Record<string, string> {
|
||
const style: Record<string, string> = {};
|
||
if (width) style['--composer-menu-desc-width'] = width;
|
||
return style;
|
||
}
|
||
const permissionMenuStyle = computed<Record<string, string>>(() => menuDescStyle(permissionDescriptionWidth.value));
|
||
const modeMenuMeasureStyle = computed<Record<string, string>>(() => menuDescStyle(modeDescriptionWidth.value));
|
||
const modesMenuInlineStyle = computed<Record<string, string>>(() => ({
|
||
...modesMenuStyle.value,
|
||
...modeMenuMeasureStyle.value,
|
||
}));
|
||
let menuMeasureFrame: number | null = null;
|
||
|
||
function cssPx(value: string): number {
|
||
const n = Number.parseFloat(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
function canvasFont(style: CSSStyleDeclaration): string {
|
||
return `${style.fontStyle || 'normal'} ${style.fontWeight || '400'} ${style.fontSize} ${style.fontFamily}`;
|
||
}
|
||
|
||
function letterSpacingPx(style: CSSStyleDeclaration): number {
|
||
return style.letterSpacing === 'normal' ? 0 : cssPx(style.letterSpacing);
|
||
}
|
||
|
||
function measureTextWidth(text: string, style: CSSStyleDeclaration): number {
|
||
if (!text) return 0;
|
||
const prepared = prepareWithSegments(text, canvasFont(style), {
|
||
letterSpacing: letterSpacingPx(style),
|
||
});
|
||
return measureNaturalWidth(prepared);
|
||
}
|
||
|
||
function measureMenuDescriptions(): void {
|
||
const probe = menuMeasureRef.value?.querySelector<HTMLElement>('.pd-desc');
|
||
if (!probe) return;
|
||
const style = getComputedStyle(probe);
|
||
const permissionWidth = Math.max(
|
||
0,
|
||
...PERM_MODES.map((opt) => measureTextWidth(t(opt.descKey), style)),
|
||
);
|
||
const modeWidth = Math.max(
|
||
0,
|
||
...MODE_DESC_KEYS.map((key) => measureTextWidth(t(key), style)),
|
||
);
|
||
permissionDescriptionWidth.value = permissionWidth > 0 ? `${Math.ceil(permissionWidth)}px` : '';
|
||
modeDescriptionWidth.value = modeWidth > 0 ? `${Math.ceil(modeWidth)}px` : '';
|
||
}
|
||
|
||
function scheduleMenuDescriptionMeasure(): void {
|
||
if (typeof window === 'undefined') return;
|
||
if (menuMeasureFrame !== null) {
|
||
window.cancelAnimationFrame(menuMeasureFrame);
|
||
}
|
||
void nextTick(() => {
|
||
menuMeasureFrame = window.requestAnimationFrame(() => {
|
||
menuMeasureFrame = null;
|
||
measureMenuDescriptions();
|
||
});
|
||
});
|
||
}
|
||
|
||
watch(locale, scheduleMenuDescriptionMeasure, { immediate: true });
|
||
|
||
onMounted(() => {
|
||
scheduleMenuDescriptionMeasure();
|
||
void document.fonts?.ready.then(scheduleMenuDescriptionMeasure);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (menuMeasureFrame !== null) {
|
||
window.cancelAnimationFrame(menuMeasureFrame);
|
||
menuMeasureFrame = null;
|
||
}
|
||
});
|
||
|
||
function choosePermission(mode: PermissionMode): void {
|
||
emit('setPermission', mode);
|
||
closePermDropdown();
|
||
}
|
||
|
||
const permInfo = computed(() => PERM_MODES.find((p) => p.mode === props.status?.permission));
|
||
const permLabel = computed(() => (permInfo.value ? t(permInfo.value.labelKey) : ''));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Model dropdown — current provider models + thinking + more
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const currentProvider = computed(() => {
|
||
return currentModel.value?.provider ?? '';
|
||
});
|
||
|
||
const providerModels = computed(() => {
|
||
if (!currentProvider.value || !props.models?.length) return [];
|
||
return props.models.filter((m) => m.provider === currentProvider.value);
|
||
});
|
||
|
||
const starredSet = computed(() => new Set(props.starredIds ?? []));
|
||
function isStarred(modelId: string): boolean {
|
||
return starredSet.value.has(modelId);
|
||
}
|
||
const starredOtherModels = computed(() => {
|
||
if (!props.models?.length) return [];
|
||
return props.models.filter(
|
||
(m) => isStarred(m.id) && m.provider !== currentProvider.value,
|
||
);
|
||
});
|
||
|
||
function selectModel(modelId: string): void {
|
||
emit('selectModel', modelId);
|
||
closeDropdown();
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="composer"
|
||
:class="{ 'drag-over': isDragOver, expanded }"
|
||
@dragover="handleDragOver"
|
||
@dragleave="handleDragLeave"
|
||
@drop="handleDrop"
|
||
>
|
||
<!-- Attachment chips (above the input row) -->
|
||
<div v-if="attachments.length > 0" class="att-strip">
|
||
<div v-for="att in attachments" :key="att.localId" class="att-chip" :class="{ 'att-error': att.error }">
|
||
<!-- Thumbnail (video shows its first frame; an icon overlays it) -->
|
||
<Tooltip :text="t('composer.previewAttachment', { name: att.name })">
|
||
<button type="button" class="att-preview" @click="openAttachmentPreview(att)">
|
||
<video v-if="att.kind === 'video'" class="att-thumb" :src="att.previewUrl" muted playsinline preload="metadata" />
|
||
<img v-else class="att-thumb" :src="att.previewUrl" :alt="att.name" />
|
||
<span v-if="att.kind === 'video'" class="att-video-badge" aria-hidden="true">
|
||
<Icon name="play" size="sm" />
|
||
</span>
|
||
</button>
|
||
</Tooltip>
|
||
<!-- Name + status -->
|
||
<span class="att-name">{{ att.name }}</span>
|
||
<!-- Spinner while uploading -->
|
||
<Spinner v-if="att.uploading" size="sm" :label="t('composer.uploading')" />
|
||
<!-- Error indicator -->
|
||
<Tooltip v-else-if="att.error" :text="t('composer.uploadFailed')">
|
||
<span class="att-err-icon">
|
||
<Icon name="info" size="sm" />
|
||
</span>
|
||
</Tooltip>
|
||
<!-- Remove button -->
|
||
<Tooltip :text="t('composer.removeNamed', { name: att.name })">
|
||
<button class="att-rm" @click="removeAttachment(att.localId)">
|
||
<Icon name="close" size="sm" />
|
||
</button>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="previewAttachment" class="att-lightbox" @click.self="closeAttachmentPreview">
|
||
<div class="att-lightbox-card">
|
||
<Tooltip :text="t('model.close')">
|
||
<button type="button" class="att-lightbox-close" @click="closeAttachmentPreview">✕</button>
|
||
</Tooltip>
|
||
<video
|
||
v-if="previewAttachment.kind === 'video'"
|
||
class="att-lightbox-media"
|
||
:src="previewAttachment.previewUrl"
|
||
controls
|
||
playsinline
|
||
/>
|
||
<img v-else class="att-lightbox-media" :src="previewAttachment.previewUrl" :alt="previewAttachment.name" />
|
||
<div class="att-lightbox-name">{{ previewAttachment.name }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main composer card -->
|
||
<div class="composer-card">
|
||
<!-- Input row with popup menus -->
|
||
<div class="cin-wrap">
|
||
<!-- Slash menu (above textarea) -->
|
||
<SlashMenu
|
||
v-if="slashOpen"
|
||
:items="slashItems"
|
||
:active-index="slashActive"
|
||
@select="selectSlashCommand"
|
||
@hover="slashActive = $event"
|
||
/>
|
||
|
||
<!-- Mention menu (above textarea) -->
|
||
<MentionMenu
|
||
v-if="mentionOpen"
|
||
:items="mentionItems"
|
||
:active-index="mentionActive"
|
||
:loading="mentionLoading"
|
||
@select="selectMentionItem"
|
||
@hover="mentionActive = $event"
|
||
/>
|
||
|
||
<div class="input-row">
|
||
<textarea
|
||
ref="textareaRef"
|
||
v-model="text"
|
||
class="ph"
|
||
:placeholder="placeholder"
|
||
rows="1"
|
||
@keydown="handleKeydown"
|
||
@compositionstart="handleCompositionStart"
|
||
@compositionend="handleCompositionEnd"
|
||
@input="handleInput"
|
||
/>
|
||
<button
|
||
v-if="expanded || isGrown"
|
||
class="expand-btn"
|
||
type="button"
|
||
:aria-label="expanded ? t('composer.collapseTitle') : t('composer.expandTitle')"
|
||
@click="toggleExpand"
|
||
>
|
||
<Icon v-if="expanded" name="collapse" size="sm" />
|
||
<Icon v-else name="expand" size="sm" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hidden file input -->
|
||
<input
|
||
v-if="hasUpload"
|
||
ref="fileInputRef"
|
||
type="file"
|
||
accept="image/*,video/*"
|
||
multiple
|
||
class="file-input-hidden"
|
||
@change="handleFileInputChange"
|
||
/>
|
||
|
||
<!-- Bottom toolbar — split into individual controls -->
|
||
<div ref="toolbarRef" class="toolbar">
|
||
<div ref="menuMeasureRef" class="menu-measure" aria-hidden="true">
|
||
<span class="pd-desc" />
|
||
</div>
|
||
|
||
<!-- Left: attach + permission + plan -->
|
||
<div class="toolbar-left">
|
||
<IconButton
|
||
v-if="hasUpload"
|
||
size="md"
|
||
:label="t('composer.attachImage')"
|
||
@click="openFilePicker"
|
||
>
|
||
<Icon name="image" />
|
||
</IconButton>
|
||
|
||
<!-- Permission pill — click to open dropdown -->
|
||
<span
|
||
v-if="status"
|
||
class="perm-pill"
|
||
:class="['perm-' + status.permission, { open: permDropdownOpen }]"
|
||
role="button"
|
||
tabindex="0"
|
||
@click.stop="togglePermDropdown"
|
||
@keydown.enter="togglePermDropdown"
|
||
@keydown.space.prevent="togglePermDropdown"
|
||
>{{ permLabel }}</span>
|
||
|
||
<!-- Permission dropdown — anchored to the toolbar left side -->
|
||
<div
|
||
v-if="permDropdownOpen && status"
|
||
class="perm-dropdown"
|
||
:style="permissionMenuStyle"
|
||
role="menu"
|
||
@click.stop
|
||
>
|
||
<button
|
||
v-for="opt in PERM_MODES"
|
||
:key="opt.mode"
|
||
class="pd-row"
|
||
:class="{ 'is-current': opt.mode === status.permission }"
|
||
role="menuitem"
|
||
@click="choosePermission(opt.mode)"
|
||
>
|
||
<span class="pd-check"><Icon v-if="opt.mode === status.permission" name="check" size="sm" /></span>
|
||
<span class="pd-info">
|
||
<span class="pd-name" :style="{ color: opt.color }">{{ t(opt.labelKey) }}</span>
|
||
<span class="pd-desc">{{ t(opt.descKey) }}</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Modes selector (plan / goal / swarm) — replaces the plan pill. -->
|
||
<div v-if="status" ref="modesRef" class="modes">
|
||
<button
|
||
type="button"
|
||
class="mode-pill"
|
||
:class="{ on: anyModeActive, open: modesOpen }"
|
||
@click.stop="toggleModes"
|
||
>
|
||
<span class="mode-label">{{ t('status.modesLabel') }}</span>
|
||
<span v-if="planOn" class="mode-tag">{{ t('status.planLabel') }}</span>
|
||
<span v-if="swarmOn" class="mode-tag">{{ t('status.swarmLabel') }}</span>
|
||
<span v-if="goalArmed" class="mode-tag">{{ t('status.goalLabel') }}</span>
|
||
</button>
|
||
|
||
<div v-if="modesOpen" ref="modesMenuRef" class="modes-menu" :style="modesMenuInlineStyle" role="menu">
|
||
<!-- Plan — functional client toggle -->
|
||
<button type="button" class="mode-row" :class="{ on: planOn }" role="menuitem" @click="emit('togglePlan')">
|
||
<span class="mode-row-icon"><Icon name="file-edit" size="sm" /></span>
|
||
<span class="mode-row-info">
|
||
<span class="mode-row-name">{{ t('status.planLabel') }}</span>
|
||
<span class="mode-row-desc">{{ t('status.planDesc') }}</span>
|
||
</span>
|
||
<span class="mode-switch" :class="{ on: planOn }"><span class="mode-knob" /></span>
|
||
</button>
|
||
<!-- Swarm — functional client toggle -->
|
||
<button type="button" class="mode-row" :class="{ on: swarmOn }" role="menuitem" @click="emit('toggleSwarm')">
|
||
<span class="mode-row-icon"><Icon name="sparkles" size="sm" /></span>
|
||
<span class="mode-row-info">
|
||
<span class="mode-row-name">{{ t('status.swarmLabel') }}</span>
|
||
<span class="mode-row-desc">{{ t('status.swarmDesc') }}</span>
|
||
</span>
|
||
<span class="mode-switch" :class="{ on: swarmOn }"><span class="mode-knob" /></span>
|
||
</button>
|
||
<!-- Goal — lifecycle controls when active; switch is on when active or armed. -->
|
||
<div class="mode-row mode-row-goal" :class="{ on: goalActive || props.goalMode }">
|
||
<button
|
||
type="button"
|
||
class="mode-row-main"
|
||
role="menuitem"
|
||
@click="goalActive ? emit('focusGoal') : emit('toggleGoal')"
|
||
>
|
||
<span class="mode-row-icon"><Icon name="target" size="sm" /></span>
|
||
<span class="mode-row-info">
|
||
<span class="mode-row-name">{{ t('status.goalLabel') }}</span>
|
||
<span class="mode-row-desc">{{ t('status.goalDesc') }}</span>
|
||
</span>
|
||
<span v-if="!goalActive" class="mode-switch" :class="{ on: props.goalMode }"><span class="mode-knob" /></span>
|
||
</button>
|
||
<div v-if="goalActive" class="mode-row-actions">
|
||
<Button
|
||
v-if="goalCanPause"
|
||
size="sm"
|
||
variant="secondary"
|
||
class="mode-row-action"
|
||
@click="emit('controlGoal', 'pause')"
|
||
>
|
||
<Icon name="pause" size="sm" />
|
||
<span>{{ t('status.goalPause') }}</span>
|
||
</Button>
|
||
<Button
|
||
v-if="goalCanResume"
|
||
size="sm"
|
||
variant="primary"
|
||
class="mode-row-action"
|
||
@click="emit('controlGoal', 'resume')"
|
||
>
|
||
<Icon name="play" size="sm" />
|
||
<span>{{ t('status.goalResume') }}</span>
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="danger-soft"
|
||
class="mode-row-action"
|
||
@click="emit('controlGoal', 'cancel')"
|
||
>
|
||
<Icon name="close" size="sm" />
|
||
<span>{{ t('status.goalCancel') }}</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Right: ctx + model -->
|
||
<div class="toolbar-right">
|
||
<!-- Compact chip when context is high -->
|
||
<button v-if="showCompact" class="compact-chip" @click.stop="emit('compact')">/compact</button>
|
||
|
||
<!-- Context meter — circular ring + token count. The ring is
|
||
aria-hidden, so the trigger exposes the full usage (used/max/pct)
|
||
via aria-label; focusable so keyboard and switch-control users
|
||
reach the same tooltip hover users see. The visible "12k/256k"
|
||
count is hidden under 980px by CSS, but SR users still get this
|
||
label. -->
|
||
<Tooltip :text="ctxTooltip">
|
||
<span
|
||
v-if="status && !hideContext"
|
||
class="ctx-group"
|
||
role="img"
|
||
tabindex="0"
|
||
:aria-label="ctxTooltip"
|
||
>
|
||
<ContextRing :pct="pct" />
|
||
<span class="ctx-num">{{ kFmt(status.ctxUsed) }}/{{ kFmt(status.ctxMax) }}</span>
|
||
</span>
|
||
</Tooltip>
|
||
|
||
<!-- Model pill — click to open quick-switch dropdown -->
|
||
<span
|
||
v-if="status"
|
||
class="model-pill"
|
||
:class="{ open: dropdownOpen }"
|
||
role="button"
|
||
tabindex="0"
|
||
@click.stop="toggleDropdown"
|
||
@keydown.enter="toggleDropdown"
|
||
@keydown.space.prevent="toggleDropdown"
|
||
>
|
||
<b>{{ status.model }}</b>
|
||
<span v-if="thinkingSuffix" class="think-suffix">{{ thinkingSuffix }}</span>
|
||
<Icon class="cv" name="chevron-down" size="sm" />
|
||
</span>
|
||
<Tooltip v-if="running" :text="t('composer.interruptTitle')">
|
||
<button
|
||
class="stop"
|
||
:aria-label="t('composer.interrupt')"
|
||
@click="emit('interrupt')"
|
||
>
|
||
<Icon name="stop" size="sm" />
|
||
</button>
|
||
</Tooltip>
|
||
<button
|
||
class="send"
|
||
:aria-label="sendLabel"
|
||
@click="handleSubmit()"
|
||
>
|
||
<Icon name="send" size="sm" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Model dropdown — current provider models + controls + more -->
|
||
<div v-if="dropdownOpen && status" class="model-dropdown" role="menu" @click.stop>
|
||
<!-- Starred models from other providers -->
|
||
<div v-if="starredOtherModels.length > 0" class="md-section">{{ t('status.starredModels') }}</div>
|
||
<button
|
||
v-for="m in starredOtherModels"
|
||
:key="m.id"
|
||
class="md-row"
|
||
:class="{ 'is-current': m.id === status.modelId }"
|
||
role="menuitem"
|
||
@click="selectModel(m.id)"
|
||
>
|
||
<span class="md-check"><Icon v-if="m.id === status.model || m.model === status.model || m.displayName === status.model" name="check" size="sm" /></span>
|
||
<span class="md-name">{{ m.displayName ?? m.model }}</span>
|
||
<span class="md-provider">{{ m.provider }}</span>
|
||
<Icon class="md-star" name="star" size="sm" />
|
||
</button>
|
||
|
||
<div v-if="starredOtherModels.length > 0" class="md-divider" />
|
||
|
||
<!-- Current provider models -->
|
||
<div v-if="providerModels.length > 0" class="md-section">{{ currentProvider }}</div>
|
||
<button
|
||
v-for="m in providerModels"
|
||
:key="m.id"
|
||
class="md-row"
|
||
:class="{ 'is-current': m.id === status.modelId }"
|
||
role="menuitem"
|
||
@click="selectModel(m.id)"
|
||
>
|
||
<span class="md-check"><Icon v-if="m.id === status.model || m.model === status.model || m.displayName === status.model" name="check" size="sm" /></span>
|
||
<span class="md-name">{{ m.displayName ?? m.model }}</span>
|
||
<Icon v-if="isStarred(m.id)" class="md-star" name="star" size="sm" />
|
||
</button>
|
||
|
||
<div v-if="providerModels.length > 0" class="md-divider" />
|
||
|
||
<!-- Thinking level — segmented control. Effort models show every
|
||
declared level; boolean models show On/Off; unsupported shows a note. -->
|
||
<div class="md-thinking" :class="{ 'is-readonly': thinkingReadonly }">
|
||
<span class="md-name">{{ t('status.thinkingLabel') }}</span>
|
||
<span
|
||
v-if="thinkingAvailability === 'unsupported'"
|
||
class="md-note"
|
||
>{{ t('status.modeNotSupported') }}</span>
|
||
<div
|
||
v-else
|
||
class="effort-segments"
|
||
role="group"
|
||
:aria-label="t('status.thinkingLabel')"
|
||
>
|
||
<button
|
||
v-for="seg in thinkingSegments"
|
||
:key="seg"
|
||
type="button"
|
||
class="effort-seg"
|
||
:class="{ 'is-active': seg === activeThinkingSegment }"
|
||
:disabled="thinkingReadonly"
|
||
@click="setThinkingSegment(seg)"
|
||
>{{ thinkingSegmentLabel(seg) }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="md-divider" />
|
||
|
||
<!-- More models → open full picker -->
|
||
<button class="md-row md-row-more" role="menuitem" @click="closeDropdown(); emit('pickModel');">
|
||
<span class="md-name">{{ t('status.moreModels') }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.composer {
|
||
padding: 7px var(--dock-inline-right, 16px) 12px var(--dock-inline-left, 16px);
|
||
background: transparent;
|
||
transition: background 0.12s;
|
||
}
|
||
|
||
.composer.drag-over {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
|
||
/* Main composer card */
|
||
.composer-card {
|
||
--composer-send-size: 32px;
|
||
--composer-send-inset: var(--space-2);
|
||
position: relative;
|
||
border: 1px solid var(--line);
|
||
border-radius: calc((var(--composer-send-size) / 2) + var(--composer-send-inset));
|
||
background: var(--bg);
|
||
box-shadow: var(--shadow-md);
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.composer-card:focus-within {
|
||
border-color: var(--color-accent);
|
||
box-shadow: var(--shadow-md), 0 0 0 3px var(--color-accent-soft);
|
||
}
|
||
|
||
|
||
|
||
/* Attachment strip */
|
||
.att-strip {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 4px 0 6px;
|
||
}
|
||
|
||
.att-chip {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
background: var(--panel2);
|
||
border: 1px solid var(--color-accent-bd);
|
||
border-radius: 4px;
|
||
padding: 3px 6px 3px 4px;
|
||
font-family: var(--mono);
|
||
font-size: calc(var(--ui-font-size) - 3px);
|
||
color: var(--color-text);
|
||
max-width: 220px;
|
||
}
|
||
|
||
.att-preview {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
border-radius: var(--radius-xs);
|
||
background: transparent;
|
||
padding: 0;
|
||
cursor: zoom-in;
|
||
flex: none;
|
||
}
|
||
.att-preview:focus-visible {
|
||
outline: 2px solid var(--color-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Play glyph over a video thumbnail so it reads as a video, not a still. */
|
||
.att-video-badge {
|
||
position: absolute;
|
||
left: 4px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 16px;
|
||
height: 16px;
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
color: var(--color-text-on-accent);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.att-chip.att-error {
|
||
border-color: var(--color-danger);
|
||
color: var(--color-danger);
|
||
}
|
||
|
||
.att-thumb {
|
||
width: 28px;
|
||
height: 28px;
|
||
object-fit: cover;
|
||
border-radius: var(--radius-xs);
|
||
flex-shrink: 0;
|
||
background: var(--line2);
|
||
}
|
||
|
||
.att-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.att-err-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
color: var(--color-danger);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.att-rm {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: none;
|
||
border: none;
|
||
padding: 1px;
|
||
cursor: pointer;
|
||
color: var(--muted);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.att-rm:hover {
|
||
color: var(--color-danger);
|
||
}
|
||
|
||
.att-lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-overlay);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
background: rgba(20, 23, 28, 0.62);
|
||
}
|
||
.att-lightbox-card {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
max-width: min(960px, calc(100vw - 48px));
|
||
max-height: calc(100vh - 48px);
|
||
}
|
||
.att-lightbox-media {
|
||
max-width: 100%;
|
||
max-height: calc(100vh - 96px);
|
||
border-radius: 6px;
|
||
background: var(--bg);
|
||
box-shadow: var(--shadow-xl);
|
||
object-fit: contain;
|
||
}
|
||
.att-lightbox-name {
|
||
max-width: 100%;
|
||
color: var(--color-text-on-accent);
|
||
font-family: var(--mono);
|
||
font-size: calc(var(--ui-font-size) - 2px);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.att-lightbox-close {
|
||
position: absolute;
|
||
top: -14px;
|
||
right: -14px;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid rgba(255,255,255,0.45);
|
||
border-radius: 50%;
|
||
background: rgba(20,23,28,0.82);
|
||
color: var(--color-text-on-accent);
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Hidden file input */
|
||
.file-input-hidden {
|
||
display: none;
|
||
}
|
||
|
||
/* Wrapper that establishes a positioning context for the popup menus */
|
||
.cin-wrap {
|
||
position: relative;
|
||
padding: 14px 16px 8px;
|
||
}
|
||
|
||
/* Input row */
|
||
.input-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
/* Expand toggle — top-right of the textarea */
|
||
.expand-btn {
|
||
width: 22px;
|
||
height: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--dim);
|
||
cursor: pointer;
|
||
padding: 0;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
|
||
.expand-btn:hover {
|
||
background: var(--panel2);
|
||
color: var(--color-text);
|
||
}
|
||
|
||
.expand-btn:focus-visible {
|
||
outline: 2px solid var(--color-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.ph {
|
||
color: var(--faint);
|
||
/* Keep the caret at the normal text colour even when the field is empty:
|
||
the empty state sets `color` to `--faint` (so the placeholder feels soft),
|
||
and an unset caret inherits that faint colour and nearly disappears. */
|
||
caret-color: var(--color-text);
|
||
flex: 1;
|
||
border: none;
|
||
outline: none;
|
||
resize: none;
|
||
font-family: var(--font-ui);
|
||
font-size: var(--content-font-size);
|
||
background: transparent;
|
||
min-height: 36px;
|
||
max-height: calc(100vh / 4);
|
||
overflow-y: auto;
|
||
line-height: 1.5;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.ph::placeholder {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.ph:not(:placeholder-shown) {
|
||
color: var(--color-text);
|
||
}
|
||
|
||
/* Expanded editor: a tall composing area at ~70% of the viewport — clearly
|
||
larger than the auto-grow cap, while leaving room for the chat header, the
|
||
bottom toolbar row, and padding so nothing gets clipped. Content beyond it
|
||
scrolls internally. */
|
||
.composer.expanded .ph {
|
||
min-height: 70vh;
|
||
max-height: 70vh;
|
||
}
|
||
|
||
/* /compact chip */
|
||
.compact-chip {
|
||
background: none;
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius-xs);
|
||
color: var(--color-warning);
|
||
font-family: var(--mono);
|
||
font-size: var(--ui-font-size);
|
||
padding: 0 4px;
|
||
cursor: pointer;
|
||
height: 19px;
|
||
line-height: 17px;
|
||
flex: none;
|
||
}
|
||
.compact-chip:hover { background: var(--panel2); }
|
||
|
||
/* Send button — circular accent icon. Always "send"; while running it enqueues
|
||
(handled upstream). Interrupt is a separate Stop button so the two are never
|
||
confused. */
|
||
.send {
|
||
width: var(--composer-send-size);
|
||
height: var(--composer-send-size);
|
||
border-radius: 50%;
|
||
background: var(--color-accent);
|
||
color: var(--color-text-on-accent); /* white on accent — readable in light and dark */
|
||
border: none;
|
||
box-shadow: var(--shadow-xs);
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
margin-left: var(--space-2);
|
||
transition: background 0.25s ease, transform 0.12s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.send:hover {
|
||
background: var(--color-accent-hover);
|
||
}
|
||
|
||
.send:active {
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
.send svg {
|
||
flex: none;
|
||
width: var(--p-ic-lg);
|
||
height: var(--p-ic-lg);
|
||
}
|
||
|
||
/* Stop button — sibling of Send, shown only while running. Red at rest so the
|
||
destructive action is easy to spot; fills solid danger on hover. Kept softer
|
||
than the accent Send so Send stays the primary action. */
|
||
.stop {
|
||
width: var(--composer-send-size);
|
||
height: var(--composer-send-size);
|
||
border-radius: 50%;
|
||
background: var(--color-danger-soft);
|
||
color: var(--color-danger);
|
||
border: 1px solid var(--color-danger-bd);
|
||
box-shadow: var(--shadow-xs);
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
margin-left: var(--space-2);
|
||
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease, transform 0.12s ease;
|
||
}
|
||
.stop:hover {
|
||
background: var(--color-danger);
|
||
color: var(--color-text-on-accent);
|
||
border-color: var(--color-danger);
|
||
}
|
||
.stop:active {
|
||
transform: scale(0.92);
|
||
}
|
||
.stop svg {
|
||
flex: none;
|
||
width: var(--p-ic-lg);
|
||
height: var(--p-ic-lg);
|
||
}
|
||
|
||
/* Bottom toolbar */
|
||
.toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px var(--composer-send-inset) var(--composer-send-inset);
|
||
position: relative;
|
||
}
|
||
|
||
.menu-measure {
|
||
position: absolute;
|
||
width: max-content;
|
||
height: 0;
|
||
overflow: hidden;
|
||
visibility: hidden;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.toolbar-left,
|
||
.toolbar-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Permission pill */
|
||
.perm-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 2px 7px;
|
||
border-radius: 6px;
|
||
font-size: var(--ui-font-size);
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.1s, color 0.15s;
|
||
font-family: var(--font-ui);
|
||
font-weight: var(--weight-medium);
|
||
}
|
||
.perm-pill:hover {
|
||
background: var(--color-surface-sunken);
|
||
}
|
||
.perm-pill.open {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
.perm-pill.perm-manual {
|
||
color: var(--dim);
|
||
}
|
||
.perm-pill.perm-yolo {
|
||
color: var(--color-warning);
|
||
}
|
||
.perm-pill.perm-auto {
|
||
color: var(--color-danger);
|
||
}
|
||
|
||
/* Context group — circular ring + num. Focusable for keyboard / switch access
|
||
to its aria-label and tooltip (see template), so it needs a focus ring. */
|
||
.ctx-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
flex-shrink: 0;
|
||
padding: 2px 4px;
|
||
border-radius: var(--radius-xs);
|
||
}
|
||
.ctx-group:focus-visible {
|
||
outline: 2px solid var(--color-accent);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
.ctx-num {
|
||
font-size: var(--ui-font-size);
|
||
color: var(--muted);
|
||
font-family: var(--font-ui);
|
||
font-variant-numeric: tabular-nums;
|
||
font-feature-settings: "tnum";
|
||
letter-spacing: 0;
|
||
line-height: 16px;
|
||
}
|
||
|
||
/* Model pill */
|
||
.model-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 2px 7px;
|
||
border-radius: 6px;
|
||
font-size: var(--ui-font-size);
|
||
line-height: var(--leading-normal);
|
||
color: var(--dim);
|
||
font-family: var(--font-ui);
|
||
font-weight: var(--weight-medium);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.1s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.model-pill:hover {
|
||
background: var(--color-surface-sunken);
|
||
color: var(--color-text);
|
||
}
|
||
.model-pill.open {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
.model-pill b {
|
||
font-weight: 500;
|
||
color: var(--color-text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
max-width: 280px;
|
||
}
|
||
.model-pill .think-suffix {
|
||
color: var(--color-accent);
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
}
|
||
.model-pill .cv {
|
||
color: var(--faint);
|
||
flex: none;
|
||
}
|
||
.model-pill:hover .cv,
|
||
.model-pill.open .cv {
|
||
color: var(--color-accent-hover);
|
||
}
|
||
|
||
/* Model dropdown — anchored to the toolbar right edge */
|
||
.model-dropdown {
|
||
position: absolute;
|
||
bottom: calc(100% + 4px);
|
||
right: 10px;
|
||
z-index: var(--z-dropdown);
|
||
min-width: 200px;
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-line);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 5px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
font-family: var(--font-ui);
|
||
}
|
||
|
||
.md-section {
|
||
padding: 4px 7px 2px;
|
||
font-size: var(--text-xs);
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0;
|
||
font-weight: var(--weight-semibold);
|
||
}
|
||
|
||
.md-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
width: 100%;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-family: var(--font-ui);
|
||
font-size: var(--ui-font-size);
|
||
color: var(--color-text);
|
||
padding: 5px 7px;
|
||
border-radius: 6px;
|
||
text-align: left;
|
||
}
|
||
.md-row:hover { background: var(--color-surface-sunken); }
|
||
.md-row:disabled {
|
||
cursor: default;
|
||
opacity: 0.58;
|
||
}
|
||
.md-row:disabled:hover { background: none; }
|
||
.md-row.is-current { color: var(--color-text); background: var(--color-accent-soft); }
|
||
.md-row.is-on { color: var(--color-accent); }
|
||
.md-note {
|
||
margin-left: auto;
|
||
color: var(--muted);
|
||
font-size: var(--ui-font-size-xs);
|
||
}
|
||
|
||
.md-row-more {
|
||
color: var(--color-accent);
|
||
font-weight: 500;
|
||
}
|
||
.md-row-more:hover {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
|
||
.md-check {
|
||
width: 14px;
|
||
flex: none;
|
||
color: var(--color-accent);
|
||
font-weight: 500;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.md-name {
|
||
flex: 1;
|
||
}
|
||
.md-provider {
|
||
color: var(--muted);
|
||
font-size: var(--ui-font-size-xs);
|
||
flex: none;
|
||
}
|
||
.md-star {
|
||
color: var(--star);
|
||
flex: none;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.md-divider {
|
||
height: 1px;
|
||
background: var(--line);
|
||
margin: 3px 0;
|
||
}
|
||
|
||
/* Thinking level segmented control — sits inside the model dropdown. */
|
||
.md-thinking {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 7px;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.md-thinking .md-name {
|
||
font-family: var(--font-ui);
|
||
font-size: var(--ui-font-size);
|
||
color: var(--color-text);
|
||
flex: none;
|
||
}
|
||
.md-thinking .md-note {
|
||
margin-left: auto;
|
||
}
|
||
.effort-segments {
|
||
margin-left: auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 1px;
|
||
padding: 2px;
|
||
background: var(--color-surface-sunken);
|
||
border: 1px solid var(--color-line);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.effort-seg {
|
||
appearance: none;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-family: var(--font-ui);
|
||
font-size: var(--ui-font-size-xs);
|
||
line-height: 1;
|
||
color: var(--color-text-muted);
|
||
padding: 4px 9px;
|
||
border-radius: var(--radius-sm);
|
||
white-space: nowrap;
|
||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||
}
|
||
.effort-seg:hover:not(:disabled):not(.is-active) {
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text);
|
||
}
|
||
.effort-seg:focus-visible {
|
||
outline: 2px solid var(--color-accent);
|
||
outline-offset: -2px;
|
||
}
|
||
.effort-seg.is-active {
|
||
background: var(--color-accent);
|
||
color: var(--color-text-on-accent);
|
||
box-shadow: var(--shadow-xs);
|
||
font-weight: 500;
|
||
}
|
||
.effort-seg:disabled {
|
||
cursor: default;
|
||
}
|
||
.md-thinking.is-readonly .effort-segments {
|
||
opacity: 0.62;
|
||
}
|
||
.md-thinking.is-readonly .effort-seg.is-active {
|
||
background: var(--color-surface-raised);
|
||
color: var(--color-text-muted);
|
||
box-shadow: none;
|
||
}
|
||
|
||
/* Permission dropdown — anchored to the toolbar left side */
|
||
.perm-dropdown {
|
||
position: absolute;
|
||
bottom: calc(100% + 4px);
|
||
left: 10px;
|
||
z-index: var(--z-dropdown);
|
||
min-width: 220px;
|
||
width: max-content;
|
||
max-width: calc(100vw - var(--space-8));
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-line);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 5px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
|
||
.pd-row {
|
||
display: grid;
|
||
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
|
||
column-gap: 7px;
|
||
row-gap: 2px;
|
||
align-items: start;
|
||
width: 100%;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 6px 7px;
|
||
border-radius: 6px;
|
||
text-align: left;
|
||
}
|
||
.pd-row:hover { background: var(--color-surface-sunken); }
|
||
.pd-row.is-current { background: var(--color-accent-soft); }
|
||
|
||
.pd-check {
|
||
grid-column: 1;
|
||
grid-row: 1;
|
||
width: 14px;
|
||
min-height: 1lh;
|
||
color: var(--color-accent);
|
||
font-size: var(--ui-font-size);
|
||
font-weight: var(--weight-medium);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
.pd-info {
|
||
display: contents;
|
||
}
|
||
|
||
.pd-name {
|
||
grid-column: 2;
|
||
grid-row: 1;
|
||
font-family: var(--font-ui);
|
||
font-size: var(--ui-font-size);
|
||
font-weight: var(--weight-medium);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
.pd-desc {
|
||
grid-column: 2;
|
||
grid-row: 2;
|
||
width: var(--composer-menu-desc-width, auto);
|
||
font-family: var(--font-ui);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-medium);
|
||
color: var(--muted);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
|
||
/* Toggle pills (Thinking / Plan) */
|
||
/* Modes selector (plan / goal / swarm) — replaces the old plan pill + badges.
|
||
z-index lifts the whole control (incl. its upward-opening menu) above the
|
||
composer input row, which otherwise paints over the menu. */
|
||
.modes { position: relative; display: inline-flex; z-index: var(--z-sticky); }
|
||
.mode-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 9px;
|
||
border: none;
|
||
background: none;
|
||
border-radius: 6px;
|
||
font-size: var(--ui-font-size);
|
||
font-family: var(--font-ui);
|
||
font-weight: var(--weight-medium);
|
||
color: var(--color-text);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: background 0.1s, color 0.15s;
|
||
}
|
||
.mode-pill:hover { background: var(--color-surface-sunken); }
|
||
.mode-pill.on { background: var(--color-accent-soft); color: var(--color-accent-hover); }
|
||
.mode-pill.open { background: var(--color-accent-soft); }
|
||
.mode-label { flex: none; }
|
||
.mode-tag {
|
||
flex: none;
|
||
font-family: var(--font-ui);
|
||
font-size: calc(var(--ui-font-size) - 3px);
|
||
color: var(--color-accent-hover);
|
||
background: var(--bg);
|
||
border: 1px solid var(--color-accent-bd);
|
||
border-radius: 999px;
|
||
padding: 0 6px;
|
||
line-height: 16px;
|
||
}
|
||
.mode-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--color-accent); flex: none; }
|
||
|
||
.modes-menu {
|
||
position: fixed;
|
||
z-index: var(--z-dropdown);
|
||
min-width: 220px;
|
||
width: max-content;
|
||
max-width: calc(100vw - var(--space-8));
|
||
background: var(--color-surface-raised);
|
||
border: 1px solid var(--color-line);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 5px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.mode-row {
|
||
display: grid;
|
||
grid-template-columns: 14px var(--composer-menu-desc-width, max-content);
|
||
column-gap: 7px;
|
||
row-gap: 2px;
|
||
align-items: start;
|
||
width: 100%;
|
||
padding: 6px 7px;
|
||
border: none;
|
||
background: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-family: var(--font-ui);
|
||
text-align: left;
|
||
}
|
||
.mode-row:hover:not(:disabled) { background: var(--color-surface-sunken); }
|
||
.mode-row:disabled { cursor: not-allowed; opacity: 0.45; }
|
||
.mode-row-info {
|
||
display: contents;
|
||
}
|
||
.mode-row-icon {
|
||
grid-column: 1;
|
||
grid-row: 1;
|
||
width: 14px;
|
||
min-height: 1lh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--muted);
|
||
font-size: var(--ui-font-size);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.mode-row-name {
|
||
grid-column: 2;
|
||
grid-row: 1;
|
||
font-size: var(--ui-font-size);
|
||
font-weight: var(--weight-medium);
|
||
color: var(--color-text);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.mode-row-desc {
|
||
grid-column: 2;
|
||
grid-row: 2;
|
||
width: var(--composer-menu-desc-width, auto);
|
||
font-size: var(--text-xs);
|
||
font-weight: var(--weight-medium);
|
||
color: var(--muted);
|
||
line-height: var(--leading-normal);
|
||
}
|
||
.mode-row-not-supported {
|
||
margin-left: auto;
|
||
font-size: var(--ui-font-size-xs);
|
||
color: var(--muted);
|
||
}
|
||
.mode-row.on {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
.mode-row.on .mode-row-name { color: var(--color-accent-hover); }
|
||
.mode-row.on .mode-row-icon { color: var(--color-accent-hover); }
|
||
.mode-row-meta { font-family: var(--mono); font-size: calc(var(--ui-font-size) - 3px); color: var(--muted); }
|
||
.mode-row:disabled .mode-row-meta { color: var(--faint); }
|
||
.mode-switch {
|
||
grid-column: 2;
|
||
grid-row: 1;
|
||
justify-self: end;
|
||
width: 34px;
|
||
height: 19px;
|
||
border-radius: 999px;
|
||
background: var(--panel2);
|
||
border: 1px solid var(--line);
|
||
position: relative;
|
||
transition: background 0.15s;
|
||
}
|
||
.mode-switch.on { background: var(--color-accent); border-color: var(--color-accent); }
|
||
.mode-knob {
|
||
position: absolute;
|
||
top: 1px;
|
||
left: 1px;
|
||
width: 15px;
|
||
height: 15px;
|
||
border-radius: 50%;
|
||
background: var(--bg);
|
||
box-shadow: var(--shadow-xs);
|
||
transition: transform 0.15s;
|
||
}
|
||
.mode-switch.on .mode-knob { transform: translateX(15px); }
|
||
|
||
.mode-row-goal {
|
||
--mode-row-icon-col: 14px;
|
||
--mode-row-col-gap: 7px;
|
||
--mode-row-pad-x: 7px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
cursor: default;
|
||
padding: 0;
|
||
gap: 0;
|
||
}
|
||
.mode-row-goal:hover { background: transparent; }
|
||
.mode-row-goal.on {
|
||
background: var(--color-accent-soft);
|
||
}
|
||
.mode-row-main {
|
||
display: grid;
|
||
grid-template-columns: var(--mode-row-icon-col) var(--composer-menu-desc-width, max-content);
|
||
column-gap: var(--mode-row-col-gap);
|
||
row-gap: 2px;
|
||
align-items: start;
|
||
width: 100%;
|
||
padding: 6px var(--mode-row-pad-x);
|
||
border: none;
|
||
background: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-family: var(--font-ui);
|
||
text-align: left;
|
||
}
|
||
.mode-row-main:hover { background: var(--color-surface-sunken); }
|
||
.mode-row-goal.on .mode-row-main .mode-row-name { color: var(--color-accent-hover); }
|
||
.mode-row-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--space-2);
|
||
justify-content: flex-start;
|
||
padding: 0 var(--mode-row-pad-x) var(--mode-row-pad-x)
|
||
calc(var(--mode-row-pad-x) + var(--mode-row-icon-col) + var(--mode-row-col-gap));
|
||
}
|
||
.mode-row-action {
|
||
flex: none;
|
||
}
|
||
.mode-row-action :deep(.ui-button__content) { gap: var(--space-1); }
|
||
.mode-row-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 4px 8px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--line);
|
||
background: var(--bg);
|
||
color: var(--color-text);
|
||
font-size: var(--ui-font-size-xs);
|
||
}
|
||
|
||
/* ---- Narrow composer toolbar ----------------------------------------------
|
||
Below a wide desktop the chat column can be narrower than the full toolbar
|
||
needs — with the sidebar open on a small window, and on phones. The desktop
|
||
toolbar shows every control on one row and toolbar-left / toolbar-right are
|
||
overflow:hidden, so without shedding ink the row clips its own content. The
|
||
context ring stays visible at every width (it is the live context-pressure
|
||
signal) but the "12k/256k" readout moves into the ring's tooltip, the model
|
||
name truncates earlier, and the permission label is capped so the ring and
|
||
the send button are never squeezed out. Mobile (≤640px) additionally hides
|
||
perm / modes via the rules below (those live in MobileSettingsSheet there). */
|
||
@media (max-width: 980px) {
|
||
/* The ring already conveys context pressure; the "12k/256k" readout lives in
|
||
the tooltip and returns at wider widths. */
|
||
.ctx-num {
|
||
display: none;
|
||
}
|
||
/* Model name was budgeted for a wide card (280px); trim it so the ring and
|
||
send button are not squeezed out on a narrow column. */
|
||
.model-pill b {
|
||
max-width: 130px;
|
||
}
|
||
/* Permission label is short (manual/yolo/auto); cap it defensively so a
|
||
longer label can never push the toolbar past its container. */
|
||
.perm-pill {
|
||
max-width: 104px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
|
||
/* ---- Mobile composer (prototype): round attach + rounded panel input +
|
||
round blue send with a soft shadow. The .cin container loses its border
|
||
and acts as a flex row; the textarea itself becomes the pill input. ---- */
|
||
@media (max-width: 640px) {
|
||
.composer {
|
||
padding:
|
||
9px
|
||
var(--dock-inline-right, max(12px, env(safe-area-inset-right)))
|
||
max(24px, env(safe-area-inset-bottom))
|
||
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
|
||
}
|
||
.composer-card {
|
||
--composer-send-size: 36px;
|
||
max-width: 100%;
|
||
}
|
||
.input-row {
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
/* Send → 36px round (hide the SVG arrow, show only the ::after glyph) */
|
||
.send {
|
||
width: var(--composer-send-size);
|
||
height: var(--composer-send-size);
|
||
min-width: var(--composer-send-size);
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
font-size: 0;
|
||
align-self: flex-end;
|
||
position: relative;
|
||
}
|
||
.send svg {
|
||
display: none;
|
||
}
|
||
.send::after {
|
||
content: "↑";
|
||
/* Fixed icon glyph size — not part of the UI font scale. */
|
||
font-size: 17px;
|
||
line-height: 1;
|
||
color: var(--bg);
|
||
}
|
||
/* Stop → 36px round "■" glyph to match the mobile Send sizing. */
|
||
.stop {
|
||
width: var(--composer-send-size);
|
||
height: var(--composer-send-size);
|
||
min-width: var(--composer-send-size);
|
||
padding: 0;
|
||
border-radius: 50%;
|
||
font-size: 0;
|
||
align-self: flex-end;
|
||
position: relative;
|
||
}
|
||
.stop svg {
|
||
display: none;
|
||
}
|
||
.stop::after {
|
||
content: "■";
|
||
/* Fixed icon glyph size — not part of the UI font scale. */
|
||
font-size: 17px;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* Mobile toolbar: hide secondary controls; attach / context ring / model /
|
||
send stay visible. Permission + plan move into the MobileSettingsSheet.
|
||
The context ring stays at every width by design — it is the live
|
||
context-pressure signal on a phone (the "12k/256k" readout is hidden here
|
||
by the ≤980px rule above and remains in the ring's tooltip). The /compact
|
||
chip also stays so compaction is one tap away at ≥80% usage. */
|
||
.perm-pill,
|
||
.modes {
|
||
display: none;
|
||
}
|
||
|
||
/* Model dropdown on mobile → anchored right with padding */
|
||
.model-dropdown {
|
||
right: 10px;
|
||
left: auto;
|
||
min-width: 180px;
|
||
max-width: calc(100vw - 24px);
|
||
}
|
||
|
||
/* Bump mobile font sizes +2px and pin input at 16px to prevent iOS zoom.
|
||
Height (min 36px / max one quarter of the viewport) is inherited from the
|
||
base .ph rule so the box auto-grows the same way on touch and desktop. */
|
||
.ph {
|
||
/* Pinned at 16px to prevent iOS auto-zoom on focus (not part of UI font scale). */
|
||
font-size: 16px;
|
||
}
|
||
.model-pill,
|
||
.attach-btn {
|
||
font-size: var(--ui-font-size);
|
||
}
|
||
.toolbar {
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.toolbar-left,
|
||
.toolbar-right {
|
||
min-width: 0;
|
||
}
|
||
.model-pill {
|
||
max-width: min(52vw, 220px);
|
||
}
|
||
.model-pill b {
|
||
max-width: min(40vw, 170px);
|
||
}
|
||
.md-row {
|
||
font-size: var(--ui-font-size);
|
||
}
|
||
.md-section {
|
||
font-size: var(--ui-font-size);
|
||
}
|
||
.md-thinking {
|
||
flex-wrap: wrap;
|
||
row-gap: 6px;
|
||
}
|
||
.md-thinking .effort-segments {
|
||
margin-left: 0;
|
||
width: 100%;
|
||
justify-content: space-between;
|
||
}
|
||
.md-thinking .effort-seg {
|
||
flex: 1;
|
||
padding: 5px 6px;
|
||
}
|
||
.pd-name {
|
||
font-size: var(--ui-font-size);
|
||
}
|
||
.pd-desc {
|
||
font-size: var(--text-xs);
|
||
}
|
||
}
|
||
|
||
/* NOTE: Composer overrides live in src/style.css (global), NOT here. Scoped
|
||
`.cin` rules did NOT reliably win the cascade against the base `.cin` (the
|
||
input stayed square + mono), so they were moved to the global sheet where they
|
||
apply. */
|
||
</style>
|