Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/web/src/components/mention-autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,17 @@ export function AgentOption({
* `<span>` label with no avatar before this). Hover reveals the remove
* `X`; omit `onRemove` for a read-only token.
*/
export function AgentToken({ candidate, onRemove }: { candidate: MentionCandidate; onRemove?: () => void }) {
export function AgentToken({
candidate,
onRemove,
mobile = false,
}: {
candidate: MentionCandidate;
onRemove?: () => void;
/** Touch surface: the remove × is always visible (no hover on touch) and its
* tap target is enlarged. Desktop keeps the hover-revealed compact ×. */
mobile?: boolean;
}) {
const label = candidate.displayName ?? candidate.name ?? candidate.agentId.slice(0, 8);
return (
<span
Expand All @@ -190,7 +200,10 @@ export function AgentToken({ candidate, onRemove }: { candidate: MentionCandidat
type="button"
onClick={onRemove}
title="Remove participant"
className="opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${label}`}
// Touch has no hover, so the × must stay visible on mobile; desktop
// keeps it hover-revealed to keep the chip row calm.
className={mobile ? "transition-opacity" : "opacity-0 transition-opacity group-hover:opacity-100"}
style={{
display: "inline-flex",
alignItems: "center",
Expand All @@ -199,9 +212,13 @@ export function AgentToken({ candidate, onRemove }: { candidate: MentionCandidat
padding: 0,
cursor: "pointer",
color: "var(--fg-3)",
// Mobile: a full 44 touch target that clears the minimum the composer
// controls use; the × glyph stays small, just centered in the box.
// Desktop keeps the compact, hover-revealed × unchanged.
...(mobile ? { width: 44, height: 44, justifyContent: "center" } : {}),
}}
>
<X className="h-3 w-3" />
<X className={mobile ? "h-4 w-4" : "h-3 w-3"} />
</button>
)}
</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const imageStoreMocks = vi.hoisted(() => ({

const meChatMocks = vi.hoisted(() => ({
addMeChatParticipants: vi.fn(),
createMeTaskChat: vi.fn(),
}));

const readStateMocks = vi.hoisted(() => ({
Expand Down Expand Up @@ -2026,6 +2027,77 @@ describe("ChatView", () => {
await act(async () => root.unmount());
});

it("mobile new-chat draft: 44-unit send, Enter does not create, chip × visible + [+] enlarged", async () => {
const { CenterPanel } = await import("../index.js");
const { DRAFT_CHAT_ID } = await import("../../conversations/index.js");
const { container, root } = await renderDom(
<CenterPanel
selectedChatId={DRAFT_CHAT_ID}
onSelectChat={() => {}}
onClearChat={() => {}}
narrow
onShowConversations={() => {}}
initialParticipantIds={["agent-1"]}
presentation="mobile"
/>,
undefined,
"/",
);
await flush();
const send = container.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
const addBtn = container.querySelector<HTMLButtonElement>('button[aria-label="Add participant"]');
const chipRemove = container.querySelector<HTMLButtonElement>('button[aria-label^="Remove "]');
if (!send || !addBtn || !chipRemove) throw new Error("Mobile new-chat controls missing");
// Composer send clears the touch minimum (mobile prop reached the composer).
expect(Number.parseInt(send.style.width, 10)).toBe(44);
// The [+] add-participant button is a full 44x44 touch target.
expect(Number.parseInt(addBtn.style.minWidth, 10)).toBe(44);
expect(Number.parseInt(addBtn.style.minHeight, 10)).toBe(44);
// The seeded chip's remove × is always visible on mobile (no hover on touch)
// and is a full 44x44 hit area.
expect(chipRemove.className).not.toContain("opacity-0");
expect(Number.parseInt(chipRemove.style.width, 10)).toBe(44);
expect(Number.parseInt(chipRemove.style.height, 10)).toBe(44);

// Enter inserts a newline; it does not create the chat (button-only submit).
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
if (!textarea) throw new Error("draft textarea missing");
await setValue(textarea, "do the thing @nova");
await act(async () => {
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
});
await flush();
expect(meChatMocks.createMeTaskChat).not.toHaveBeenCalled();

await act(async () => root.unmount());
});

it("desktop new-chat draft: compact send and hover-revealed chip ×", async () => {
const { CenterPanel } = await import("../index.js");
const { DRAFT_CHAT_ID } = await import("../../conversations/index.js");
const { container, root } = await renderDom(
<CenterPanel
selectedChatId={DRAFT_CHAT_ID}
onSelectChat={() => {}}
onClearChat={() => {}}
narrow={false}
onShowConversations={null}
initialParticipantIds={["agent-1"]}
/>,
undefined,
"/",
);
await flush();
const send = container.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
const chipRemove = container.querySelector<HTMLButtonElement>('button[aria-label^="Remove "]');
if (!send || !chipRemove) throw new Error("Desktop new-chat controls missing");
expect(Number.parseInt(send.style.width, 10)).toBe(28);
// Desktop keeps the compact hover-revealed × .
expect(chipRemove.className).toContain("opacity-0");

await act(async () => root.unmount());
});

it("renders an agent worktree-path link as plain text while keeping real web links (issue 831)", async () => {
const { ChatView } = await import("../chat-view.js");
const worktree = "/Users/u/.first-tree/data/workspaces/a/worktrees/build-tree";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,7 @@ export function NewChatDraft({
pickerContainerRef={pickerContainerRef}
onAdd={addChip}
onRemove={removeChip}
mobile={mobile}
/>
{/* Attachment preview row — between the chip row and the textarea. */}
{pendingAttachments.length > 0 && (
Expand Down Expand Up @@ -927,6 +928,7 @@ function ParticipantChips({
pickerContainerRef,
onAdd,
onRemove,
mobile,
}: {
chips: string[];
candidates: MentionCandidate[];
Expand All @@ -943,6 +945,9 @@ function ParticipantChips({
pickerContainerRef: React.RefObject<HTMLDivElement | null>;
onAdd: (agentId: string) => void;
onRemove: (agentId: string) => void;
/** Touch surface: enlarge the [+] add-participant button and keep each chip's
* remove × visible + tappable (desktop keeps the compact hover affordances). */
mobile: boolean;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
const [highlight, setHighlight] = useState(0);
Expand Down Expand Up @@ -1034,7 +1039,7 @@ function ParticipantChips({
displayName: null,
managedByMe: false,
};
return <AgentToken key={id} candidate={cand} onRemove={() => onRemove(id)} />;
return <AgentToken key={id} candidate={cand} onRemove={() => onRemove(id)} mobile={mobile} />;
})}

<div ref={pickerContainerRef} style={{ position: "relative" }}>
Expand All @@ -1045,17 +1050,23 @@ function ParticipantChips({
aria-label="Add participant"
aria-haspopup="listbox"
aria-expanded={pickerOpen}
className="inline-flex items-center transition-colors hover:bg-[var(--bg-sunken)]"
className={cn(
"inline-flex items-center transition-colors hover:bg-[var(--bg-sunken)]",
mobile && "justify-center",
)}
style={{
padding: "var(--sp-0_5) var(--sp-1)",
borderRadius: "var(--radius-chip)",
border: "var(--hairline) solid var(--border)",
background: "transparent",
color: "var(--fg-3)",
cursor: "pointer",
// Mobile: a full 44 touch target matching the composer controls;
// desktop keeps the compact affordance.
...(mobile ? { minWidth: 44, minHeight: 44 } : {}),
}}
>
<Plus className="h-3 w-3" />
<Plus className={mobile ? "h-4 w-4" : "h-3 w-3"} />
</button>
{pickerOpen && (
<div
Expand Down
Loading