diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ec2c0f10..7ed7fe3a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -417,6 +417,30 @@ Retry" rows. `ApiError` message to `Error: …`; use `notify.error(messageOf(e))`. Don't hand-roll full-page error markup when `` fits. +### 2.18 Action icons — semantic convention + +One glyph, one action family. A `+`-shaped icon is not a generic "do +something here" mark — it specifically means **create a brand-new resource**. +Attaching, uploading, or adding an *existing* thing each has its own glyph, so +two different actions never share a mark on the same surface (the bug this +codifies: the Workbench file panel once drew *create-file* and *add-to-context* +both as a bare `Plus`). + +| Action family | Icon | Applies to | +|---|---|---| +| Create a new resource | `Plus` (bare) | file, folder, session, channel, DM, workspace, permission rule/grant, table row, kanban task | +| Create rooted at a folder | `FolderPlus` | a session rooted at a directory (RemoteWorkspace tree) | +| Attach / upload a file to the message | `Paperclip` | composer "Attach file" (upload / pick channel file), channel files | +| Add a Cheers resource to context | `MessageSquarePlus` | workbench file, ViewBoard, workspace-file reference, suggested-context chip, the composer "Add context" menu — the bundle rides your *next message* | +| Add a selected passage to context | `TextQuote` | a ranged `fs.read` excerpt — a labeled sub-variant of add-to-context (keeps its "quote a passage" meaning) | +| Add a person | `UserPlus` | add friend, invite member (decorative adornments on member-search inputs use it too) | +| `+N` count / overflow | literal `+N` text, **no icon** | avatar-stack overflow, diff added-lines — a quantity, not an action | + +Add-to-context is centralized in `AttachContextButton` +(`src/features/chat/context/ContextPickBar.tsx`) — reuse it rather than +hand-drawing an attach glyph. `Paperclip` is reserved: it never means +"add to context" (that pipeline is resource references, not uploaded files). + --- @@ -455,3 +479,5 @@ Reject in review: - [ ] new tab / empty-state / spinner styles when §2 already has one - [ ] `toast.error(String(e))` — use `notify.error(messageOf(e))` (§2.17) - [ ] hand-rolled error banners / full-page error markup when §2.17 has a tier for it; 401 handling at a call site (the client classifier owns it) +- [ ] `Plus` on anything that isn't "create a brand-new resource" — add-to-context is `MessageSquarePlus`, attach-file is `Paperclip`, add-person is `UserPlus` (§2.18) +- [ ] a new add-to-context affordance drawn with any glyph other than `MessageSquarePlus` (or `TextQuote` for a ranged passage) — or bypassing `AttachContextButton` (§2.18) diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index e3b0a1e7..1a788275 100644 --- a/frontend/src/features/chat/ChannelView.tsx +++ b/frontend/src/features/chat/ChannelView.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo, lazy, Suspense } fro import { ArrowLeft, Hash, Users, Loader2, PanelRight, PanelLeftClose, PanelLeftOpen, Paperclip, FolderTree, Settings, LayoutDashboard, Reply, X, Copy, Forward, WifiOff } from "lucide-react"; import toast from "react-hot-toast"; import { listMessages, sendMessage } from "@/api/messages"; -import { useContextPickStore, toBundle } from "./context/contextPick"; +import { useContextPickStore, toBundle, type ContextItem } from "./context/contextPick"; import { ContextPickBar } from "./context/ContextPickBar"; import { listChannelMembers, markChannelRead, joinChannel } from "@/api/channels"; import { useChatStore } from "@/stores/chatStore"; @@ -114,6 +114,9 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P const [workspaceFocus, setWorkspaceFocus] = useState([]); // Composer session target: "" = Auto (mention routing → primary); else a session_id. const [selectedSessionId, setSelectedSessionId] = useState(""); + // The owning bot of the pinned session (null for Auto) — narrows the composer's + // model chip to the single bot a pinned session actually targets. + const [selectedSessionBotId, setSelectedSessionBotId] = useState(null); // Bots @mentioned in the current draft (from the composer), so we can show their // mode/config controls inline when the caller is allowed to change them. const [mentionedBots, setMentionedBots] = useState([]); @@ -160,6 +163,7 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P // can't synthesize a phantom bubble in the newly opened channel. useEffect(() => { setSelectedSessionId(""); + setSelectedSessionBotId(null); setMentionedBots([]); setCommands([]); setMembersOpen(false); @@ -657,11 +661,51 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P const [refError, setRefError] = useState(null); // Jump-to-message request from ViewBoard history items (activity rows, audit // cards). `nonce` lets a repeat click on the same row re-trigger the scroll. - // Best-effort: MessageList only scrolls when the message is loaded. + // If the target isn't in the loaded window (initial load is only the newest + // page), page older history in — bounded — until it appears, then focus; the + // old behavior just toasted "scroll up to load older history" at the user. const [focusMsg, setFocusMsg] = useState<{ msgId: string; nonce: number } | null>(null); + const messagesRef = useRef(messages); + messagesRef.current = messages; + const jumpBackfillRef = useRef(false); + // × 50/page — comfortably covers the Activity board's 200-event window. + const JUMP_BACKFILL_PAGES = 8; const jumpToMessage = useCallback( - (msgId: string) => setFocusMsg((prev) => ({ msgId, nonce: (prev?.nonce ?? 0) + 1 })), - [] + async (msgId: string) => { + const focus = () => setFocusMsg((prev) => ({ msgId, nonce: (prev?.nonce ?? 0) + 1 })); + if (messagesRef.current.some((m) => m.msg_id === msgId)) return focus(); + if (!channel || jumpBackfillRef.current) return; + jumpBackfillRef.current = true; + const toastId = toast.loading("Loading older history…"); + try { + let oldest = messagesRef.current[0]; + for (let page = 0; page < JUMP_BACKFILL_PAGES && oldest; page++) { + const res = await listMessages(channel.channel_id, { + before: oldest.msg_id, + limit: 50, + }); + const batch = res.messages ?? res.data ?? []; + setMessages((prev) => mergeMessages(prev, batch)); + setHasMore(res.meta?.has_more_before ?? false); + // Focus in the same batch as setMessages: MessageList's focus effect + // runs after the commit that renders the new rows, so the anchor exists. + if (batch.some((m) => m.msg_id === msgId)) return focus(); + const sorted = sortMessages(batch); + if (!sorted.length || !(res.meta?.has_more_before ?? false)) break; + oldest = sorted[0]; + } + toast("Couldn't find that message in this channel's history", { + icon: "🔍", + id: "jump-not-found", + }); + } catch { + toast.error("Couldn't load older messages — try again", { id: "load-older-failed" }); + } finally { + toast.dismiss(toastId); + jumpBackfillRef.current = false; + } + }, + [channel] ); // "Open the ViewBoard on THIS board" request (same nonce pattern as focusMsg) — // the session chip's "Manage sessions…" jumps straight to the Sessions board. @@ -672,6 +716,37 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P setFocusBoard((prev) => ({ id: "sessions", nonce: (prev?.nonce ?? 0) + 1 })); }, []); + // Add-context menu → open a side surface (untargeted) so the user can pick a + // file to attach from its own panel. + const browseWorkbench = useCallback(() => { + setWbTarget(undefined); + setWbOpen(true); + }, []); + const browseWorkspace = useCallback(() => { + setWsInit({}); + setWsOpen(true); + }, []); + // A pending context chip → jump to where that resource actually lives: a + // Workbench file (`fs.read`) opens the Workbench focused on it; a bot's + // workspace file (`workspace.read`) opens the Remote workspace at that path. + const jumpToContextSource = useCallback((item: ContextItem) => { + if (item.verb === "fs.read") { + const path = item.params.path; + if (typeof path === "string") { + setWbTarget(path); + setWbOpen(true); + } + } else if (item.verb === "workspace.read") { + const botId = item.params.bot_id; + const path = item.params.path; + setWsInit({ + botId: typeof botId === "string" ? botId : undefined, + path: typeof path === "string" ? path : undefined, + }); + setWsOpen(true); + } + }, []); + // Stable handlers for the memoized drawers so a streaming re-render of ChannelView // doesn't hand them fresh closures (which would defeat React.memo). const closeViewBoard = useCallback(() => setVbOpen(false), []); @@ -688,26 +763,39 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P channelId={channel.channel_id} bots={switcherBots} value={selectedSessionId} - onChange={setSelectedSessionId} + onChange={(sid, botId) => { + setSelectedSessionId(sid); + setSelectedSessionBotId(botId ?? null); + }} sendResourceReq={sendResourceReq} onManageSessions={openSessionsBoard} /> - {/* Model/mode + config for the @mentioned bot(s); with no live - mention, fall back to the channel's bots so the controls are - always reachable. */} + {/* Model/mode + config for the target bot(s). A pinned session routes + to exactly one bot (its own), so narrow to that bot and show its + session's model; otherwise the @mentioned bots, falling back to the + channel's bots so the controls are always reachable. */} 0 - ? mentionedBots.map((m) => ({ botId: m.id, name: m.label })) - : switcherBots + selectedSessionId && selectedSessionBotId + ? [ + { + botId: selectedSessionBotId, + name: + switcherBots.find((b) => b.botId === selectedSessionBotId)?.name ?? + selectedSessionBotId, + }, + ] + : mentionedBots.length > 0 + ? mentionedBots.map((m) => ({ botId: m.id, name: m.label })) + : switcherBots } selectedSessionId={selectedSessionId} /> ) : null, // sendResourceReq and openSessionsBoard are identity-stable (useCallback). - [channel, switcherBots, selectedSessionId, mentionedBots, sendResourceReq, openSessionsBoard] + [channel, switcherBots, selectedSessionId, selectedSessionBotId, mentionedBots, sendResourceReq, openSessionsBoard] ); // In-flight bot turns, for the composer's send→stop morph. The array identity @@ -1291,6 +1379,9 @@ export function ChannelView({ channel, onBack, sidebarOpen, onToggleSidebar }: P replyTo={replyTo} draftText={draftText} files={channelFiles} + onBrowseWorkbench={browseWorkbench} + onBrowseWorkspace={browseWorkspace} + onJumpToSource={jumpToContextSource} /> )} diff --git a/frontend/src/features/chat/MessageList.tsx b/frontend/src/features/chat/MessageList.tsx index c43c3829..171f130a 100644 --- a/frontend/src/features/chat/MessageList.tsx +++ b/frontend/src/features/chat/MessageList.tsx @@ -40,7 +40,7 @@ interface Props { selectedIds?: ReadonlySet; /** Jump request from outside (ViewBoard history items): scroll the message into * view and flash it. `nonce` distinguishes repeat jumps to the same message. - * Best-effort — silently ignored when the message isn't in the loaded window. */ + * The sender (ChannelView) backfills history first, so the target is loaded. */ focusMsg?: { msgId: string; nonce: number } | null; } @@ -64,21 +64,33 @@ export function MessageList({ const [highlightId, setHighlightId] = useState(null); // External jump (ViewBoard history rows): scroll to the anchored row + flash. - // Best-effort — if the message isn't in the loaded window there is no anchor - // and the jump is a silent no-op (lightweight by design). + // ChannelView backfills older pages before focusing, so by the time focusMsg + // lands the message is loaded — no anchor now means the row exists but isn't + // rendered (e.g. a resolved approval folded into the bot turn's trace). useEffect(() => { if (!focusMsg) return; const el = containerRef.current?.querySelector( `[data-msg-id="${CSS.escape(focusMsg.msgId)}"]` ); if (!el) { - toast("Message isn't loaded — scroll up to load older history", { icon: "🔍" }); + toast("This message isn't shown in the channel view", { icon: "🔍", id: "jump-hidden" }); return; } el.scrollIntoView({ block: "center", behavior: "smooth" }); setHighlightId(focusMsg.msgId); + // content-visibility rows above the target materialize their real heights + // during the smooth scroll (backfilled pages arrive with 80px estimates), + // drifting the anchor — one instant corrective pass after it settles. + const settle = setTimeout(() => { + containerRef.current + ?.querySelector(`[data-msg-id="${CSS.escape(focusMsg.msgId)}"]`) + ?.scrollIntoView({ block: "center" }); + }, 700); const t = setTimeout(() => setHighlightId(null), 1800); - return () => clearTimeout(t); + return () => { + clearTimeout(settle); + clearTimeout(t); + }; }, [focusMsg]); // Resolved approvals are folded into each bot turn's trace, not shown as their own rows. diff --git a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx index 1afd8b07..81f84d80 100644 --- a/frontend/src/features/chat/RemoteWorkspaceDialog.tsx +++ b/frontend/src/features/chat/RemoteWorkspaceDialog.tsx @@ -18,7 +18,7 @@ import { File, FolderTree, Download, - Paperclip, + MessageSquarePlus, FileText, Folder, FolderPlus, @@ -1823,7 +1823,7 @@ export function RemoteWorkspaceDialog({ )} className="flex items-center gap-1 px-2 py-0.5 rounded hover:bg-zinc-800 text-zinc-300" > - {attached ? ADDED_TO_CONTEXT : ADD_TO_CONTEXT} + {attached ? ADDED_TO_CONTEXT : ADD_TO_CONTEXT} )} diff --git a/frontend/src/features/chat/SessionChip.tsx b/frontend/src/features/chat/SessionChip.tsx index 481e5d9c..bfec1695 100644 --- a/frontend/src/features/chat/SessionChip.tsx +++ b/frontend/src/features/chat/SessionChip.tsx @@ -61,7 +61,9 @@ export function SessionChip({ bots: SwitcherBot[]; /** "" = Auto (no session_id sent; mention routing → primary sessions). */ value: string; - onChange: (sessionId: string) => void; + /** `botId` is the session's owning bot (omitted for Auto) — lets the composer + * narrow the model chip to the single bot this pinned session targets. */ + onChange: (sessionId: string, botId?: string) => void; sendResourceReq: SendResourceReq; /** Open the ViewBoard focused on the Sessions board. */ onManageSessions: () => void; @@ -202,12 +204,12 @@ export function SessionChip({ function select(next: string) { setOpen(false); if (next === value) return; - onChange(next); + const t = next ? entries.find((s) => s.session_id === next) : undefined; + onChange(next, t?.bot_id); if (!next) { toast("Default routing restored (@mention → primary session)"); return; } - const t = entries.find((s) => s.session_id === next); if (t) toast.success(`Switched · messages will go directly to @${t.bot_name} (${tagOf(t)})`); } @@ -472,8 +474,8 @@ export function SessionChip({ // should go where they just created. The awaited load keeps the // stale-target reset from bouncing the fresh id back to Auto. void load().then((next) => { - if (next?.some((s) => s.session_id === created.session_id)) - onChange(created.session_id); + const entry = next?.find((s) => s.session_id === created.session_id); + if (entry) onChange(created.session_id, entry.bot_id); }); }} /> diff --git a/frontend/src/features/chat/context/ContextPickBar.tsx b/frontend/src/features/chat/context/ContextPickBar.tsx index aa6d9467..8fd225e6 100644 --- a/frontend/src/features/chat/context/ContextPickBar.tsx +++ b/frontend/src/features/chat/context/ContextPickBar.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { - Plus, + MessageSquarePlus, Check, X, ListChecks, @@ -10,6 +10,9 @@ import { Boxes, DollarSign, CornerDownRight, + ArrowUpRight, + PanelRight, + FolderTree, type LucideIcon, } from "lucide-react"; import { PopoverPanel, usePopoverDismiss } from "@/components/ui/popover"; @@ -113,6 +116,16 @@ const QUICK: ContextItem[] = [ { id: "cost", verb: "channel.usage.read", params: {}, label: "Cost", kind: "cost" }, ]; +/** Which surface a pending context item can be jumped back to — a workbench + * file (`fs.read`) opens the Workbench focused on it; a bot's workspace file + * (`workspace.read`) opens the Remote workspace dialog at that path. Anything + * else (channel reads: plan/activity/sessions/cost) has no file to jump to. */ +export function jumpTargetOf(it: ContextItem): "workbench" | "workspace" | null { + if (it.verb === "fs.read") return "workbench"; + if (it.verb === "workspace.read") return "workspace"; + return null; +} + /** In-panel "attach this to my next message" button (Viewboard / Workbench / * a message). Pushes one item to the channel's pending context; shows a check * once added. `disabled` (e.g. an already-pinned file) blocks the attach. */ @@ -146,7 +159,7 @@ export function AttachContextButton({ "rounded p-0.5 text-zinc-500 hover:text-indigo-300 disabled:opacity-40 disabled:hover:text-zinc-500" } > - {added ? : } + {added ? : } ); } @@ -156,11 +169,20 @@ export function ContextPickBar({ replyTo, draftText, files, + onBrowseWorkbench, + onBrowseWorkspace, + onJumpToSource, }: { channelId: string; replyTo?: ReplyTargetLike | null; draftText?: string; files?: FileRef[]; + /** Open the Workbench drawer so the user can pick a file to attach. */ + onBrowseWorkbench?: () => void; + /** Open the Remote workspace dialog so the user can pick a workspace file. */ + onBrowseWorkspace?: () => void; + /** Jump to a pending item's source (Workbench file / workspace file). */ + onJumpToSource?: (item: ContextItem) => void; }) { const items = usePendingContext(channelId); const suggestions = useContextSuggestions(channelId, { replyTo, draftText, files }); @@ -190,7 +212,7 @@ export function ContextPickBar({ > {sg.label} - + + )} @@ -235,7 +269,7 @@ export function ContextPickBar({ title={ADD_CONTEXT_MENU_TITLE} className="inline-flex items-center gap-1 rounded-lg bg-zinc-800/60 px-2 py-1 text-[11px] text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200 transition-colors" > - + {ADD_CONTEXT_MENU} {open && ( @@ -263,9 +297,45 @@ export function ContextPickBar({ ); })} -

- Files, messages & a bot's workspace files: add them from their own - panels (Workbench, a reply, the Remote workspace). + {(onBrowseWorkbench || onBrowseWorkspace) && ( + <> +

+ Browse & attach +

+ {onBrowseWorkbench && ( + + )} + {onBrowseWorkspace && ( + + )} + + )} +

+ Or attach a message from its reply action.

)}