diff --git a/packages/web/src/components/mention-autocomplete.tsx b/packages/web/src/components/mention-autocomplete.tsx index 72af134df..d97dc8828 100644 --- a/packages/web/src/components/mention-autocomplete.tsx +++ b/packages/web/src/components/mention-autocomplete.tsx @@ -164,7 +164,17 @@ export function AgentOption({ * `` 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 ( - + )} diff --git a/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx b/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx index faf40ad93..062f12158 100644 --- a/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx +++ b/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx @@ -63,6 +63,7 @@ const imageStoreMocks = vi.hoisted(() => ({ const meChatMocks = vi.hoisted(() => ({ addMeChatParticipants: vi.fn(), + createMeTaskChat: vi.fn(), })); const readStateMocks = vi.hoisted(() => ({ @@ -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( + {}} + onClearChat={() => {}} + narrow + onShowConversations={() => {}} + initialParticipantIds={["agent-1"]} + presentation="mobile" + />, + undefined, + "/", + ); + await flush(); + const send = container.querySelector('button[aria-label="Send"]'); + const addBtn = container.querySelector('button[aria-label="Add participant"]'); + const chipRemove = container.querySelector('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("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( + {}} + onClearChat={() => {}} + narrow={false} + onShowConversations={null} + initialParticipantIds={["agent-1"]} + />, + undefined, + "/", + ); + await flush(); + const send = container.querySelector('button[aria-label="Send"]'); + const chipRemove = container.querySelector('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"; diff --git a/packages/web/src/pages/workspace/conversations/new-chat-draft.tsx b/packages/web/src/pages/workspace/conversations/new-chat-draft.tsx index 94417cc3b..7e9968360 100644 --- a/packages/web/src/pages/workspace/conversations/new-chat-draft.tsx +++ b/packages/web/src/pages/workspace/conversations/new-chat-draft.tsx @@ -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 && ( @@ -927,6 +928,7 @@ function ParticipantChips({ pickerContainerRef, onAdd, onRemove, + mobile, }: { chips: string[]; candidates: MentionCandidate[]; @@ -943,6 +945,9 @@ function ParticipantChips({ pickerContainerRef: React.RefObject; 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(null); const [highlight, setHighlight] = useState(0); @@ -1034,7 +1039,7 @@ function ParticipantChips({ displayName: null, managedByMe: false, }; - return onRemove(id)} />; + return onRemove(id)} mobile={mobile} />; })}
@@ -1045,7 +1050,10 @@ 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)", @@ -1053,9 +1061,12 @@ function ParticipantChips({ 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 } : {}), }} > - + {pickerOpen && (