Merge v1.12 track A: container guidance + skills

This commit is contained in:
2026-05-21 15:11:12 +00:00
11 changed files with 482 additions and 79 deletions

View File

@@ -87,9 +87,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
// Batch 9.6: slash-command dropdown. Opens when `/` is the first char of
// the input and stays open while the input is `/<word>` with no whitespace.
// Disabled entirely when the caller doesn't pass onSlashCommand.
// v1.12 CP7.5: anchorRect was a snapshot taken at open time. SkillSlashCommand
// now reads the live textarea rect via inputRef (textareaRef below) so it can
// recompute on visualViewport changes (iOS keyboard open/close), so the
// anchorRect field is no longer needed in this state.
const [slashState, setSlashState] = useState<{
query: string;
anchorRect: { top: number; left: number };
} | null>(null);
const { skills } = useSkills();
const skillsLookup = useMemo(() => {
@@ -268,10 +271,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
if (onSlashCommand && /^\/[^\s]*$/.test(newValue)) {
const query = newValue.slice(1);
if (!slashState) {
const rect = ta.getBoundingClientRect();
setSlashState({ query, anchorRect: { top: rect.top, left: rect.left } });
setSlashState({ query });
} else if (slashState.query !== query) {
setSlashState({ ...slashState, query });
setSlashState({ query });
}
if (mentionState?.open) setMentionState(null);
return;
@@ -659,7 +661,7 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
<SkillSlashCommand
query={slashState.query}
skills={skills}
anchorRect={slashState.anchorRect}
inputRef={textareaRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>

View File

@@ -1,19 +1,36 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
import type { Skill } from '@/api/types';
interface Props {
query: string;
skills: Skill[];
anchorRect: { top: number; left: number };
// v1.12 CP7.5: was `anchorRect: {top, left}` (snapshot at open time). Now a
// live ref so the dropdown can re-stat the input on visualViewport events —
// critical on iOS where the keyboard shifts the visual viewport and the
// dropdown would otherwise sit in the wrong place (often hidden).
inputRef: RefObject<HTMLElement | null>;
onSelect: (skillName: string) => void;
onClose: () => void;
}
// max-h-[320px] on the popover — use as the height budget for above/below
// fit decisions. Slightly under-estimates when the list is short, but the
// only consequence is we sometimes flip below when we'd fit above; no UX
// breakage either way.
const DROPDOWN_HEIGHT_BUDGET = 320;
// Batch 9.6: slash-command dropdown. Models FileMentionPopover's pattern —
// fixed-positioned popover, keyboard nav, click-outside-to-close. shadcn
// `Command` (cmdk) isn't installed in this project; per the addendum we use
// a plain div + Tailwind instead of pulling a new primitive autonomously.
//
// v1.12 CP7.5: portalled to document.body (escapes transformed/will-change
// ancestor stacking contexts that hid the popover inside ChatInput on iOS)
// + visualViewport-aware positioning (handles keyboard open/close + the iOS
// "shift layout to keep input visible" auto-scroll).
// Case-insensitive prefix match on `name` only. Description is display-only
// in v1 (substring search across description is deferred to a polish batch).
@@ -28,13 +45,43 @@ function filterByPrefix(skills: Skill[], query: string): Skill[] {
return [...filtered].sort((a, b) => a.name.localeCompare(b.name));
}
export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose }: Props) {
export function SkillSlashCommand({ query, skills, inputRef, onSelect, onClose }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(skills, query), [skills, query]);
// Anchor + viewport tracking. `rect` is the input's bounding rect in layout
// viewport coords. `vvTick` forces a re-render whenever visualViewport
// changes even if the rect itself didn't (e.g. user scrolled the visual
// viewport without the input moving in layout space).
const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null,
);
const [vvTick, setVvTick] = useState(0);
useEffect(() => { setHighlightIndex(0); }, [query]);
// v1.12 CP7.5: recalc on viewport changes. iOS Safari fires
// visualViewport.resize when the soft keyboard opens/closes; .scroll fires
// when the page is shifted to keep the focused input visible above the
// keyboard. Both events should trigger a position recompute.
useEffect(() => {
function recalc() {
setRect(inputRef.current?.getBoundingClientRect() ?? null);
setVvTick((t) => t + 1);
}
recalc();
const vv = window.visualViewport;
vv?.addEventListener('resize', recalc);
vv?.addEventListener('scroll', recalc);
window.addEventListener('resize', recalc);
return () => {
vv?.removeEventListener('resize', recalc);
vv?.removeEventListener('scroll', recalc);
window.removeEventListener('resize', recalc);
};
}, [inputRef]);
// Arrow / Enter / Tab / Escape. Bound on document so keystrokes from the
// textarea reach the popover even though focus stays in the textarea.
useEffect(() => {
@@ -74,32 +121,62 @@ export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlightIndex]);
// Anchor sits above the input — translate(-100%) on Y so the dropdown
// expands upward from the anchor point rather than over the textarea.
const style = {
top: anchorRect.top,
left: anchorRect.left,
transform: 'translateY(-100%)',
} as const;
// v1.12 CP7.5: visualViewport-corrected positioning. getBoundingClientRect
// returns layout-viewport coords; iOS Safari's `position: fixed` positions
// relative to the layout viewport too — but the visible area can be offset
// (vv.offsetTop/offsetLeft) when iOS scrolls the input above the keyboard.
// Subtracting the vv offsets keeps the dropdown locked to the input's
// visual position. vvTick is in the dep list to force recompute on
// visualViewport events even when the rect itself didn't change.
//
// Default: position above the input (matches original UX). Flip below if
// above doesn't fit (input too close to top of visible viewport). When
// below would overlap the keyboard, cap top so the dropdown stays visible.
const style = useMemo<CSSProperties>(() => {
if (!rect) return { display: 'none' };
const vv = window.visualViewport;
const vvOffsetTop = vv?.offsetTop ?? 0;
const vvOffsetLeft = vv?.offsetLeft ?? 0;
const vvHeight = vv?.height ?? window.innerHeight;
if (filtered.length === 0) {
return (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
);
}
const anchorTop = rect.top - vvOffsetTop;
const anchorBottom = rect.bottom - vvOffsetTop;
const left = rect.left - vvOffsetLeft;
return (
const fitsAbove = anchorTop >= DROPDOWN_HEIGHT_BUDGET;
if (fitsAbove) {
// translate(-100%) on Y so the dropdown grows upward from anchorTop.
return {
position: 'fixed',
top: anchorTop,
left,
transform: 'translateY(-100%)',
};
}
// Render below; clamp so the bottom edge stays inside the visible viewport.
const maxTop = Math.max(0, vvHeight - DROPDOWN_HEIGHT_BUDGET);
return {
position: 'fixed',
top: Math.min(anchorBottom, maxTop),
left,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]);
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
className="fixed z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
style={style}
>
<div className="text-xs text-muted-foreground px-2 py-1">
{query ? `No skill starts with "/${query}"` : 'No skills available'}
</div>
</div>
) : (
<div
ref={popoverRef}
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto"
style={style}
>
{filtered.map((skill, i) => (
@@ -134,4 +211,11 @@ export function SkillSlashCommand({ query, skills, anchorRect, onSelect, onClose
))}
</div>
);
// v1.12 CP7.5: portal to document.body to escape ChatInput's stacking
// context. The original render-in-place rendered the dropdown inside the
// composer's transformed/will-change ancestor tree, which on iOS Safari +
// Vivaldi caused the popover to either disappear or sit at z-index 0
// behind the autofill toolbar. document.body has no transform ancestor.
return createPortal(popover, document.body);
}

View File

@@ -49,6 +49,14 @@ export function formatToolArgs(name: string, args: Record<string, unknown>): str
if (name === 'git_status') {
return '';
}
if (name === 'skill_use') {
// Schema (apps/server/src/services/tools.ts SkillUseInput) uses `name`;
// fall back to `skill_name` defensively in case a model emits that key.
return truncate(
String(args.name ?? (args as { skill_name?: unknown }).skill_name ?? '<unknown>'),
ARG_SUMMARY_MAX,
);
}
// Unknown tool — surface first arg value or the literal {} so the user can
// see something happened. Forward-compatible with future tools.
const keys = Object.keys(args);