From c81b39cb90918d458c0ae2c7fd1536f6fbc700e0 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 14:38:10 +0800 Subject: [PATCH 1/3] fix(chat): backfill older history when jumping to an unloaded message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jumping from a ViewBoard history row (Activity/Audit) to a message outside the loaded window used to just toast "scroll up to load older history". Now ChannelView pages older history in (50/page, bounded at 8 pages — covers the Activity board's 200-event window) until the target appears, then focuses it; the toast only remains for messages that genuinely can't be found or shown (e.g. resolved approvals folded into the bot trace). MessageList adds one instant corrective scroll pass after the smooth scroll settles, since backfilled rows above the target materialize their real heights mid-scroll (content-visibility estimates) and drift the anchor. Verified live on the kind stack: jump to an old episode fetches one page (46→81 rows), lands the target centered in-viewport with the flash ring, no stale toast, zero console errors. Co-Authored-By: Claude Fable 5 --- frontend/src/features/chat/ChannelView.tsx | 46 ++++++++++++++++++++-- frontend/src/features/chat/MessageList.tsx | 22 ++++++++--- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index e3b0a1e7..3bf2551c 100644 --- a/frontend/src/features/chat/ChannelView.tsx +++ b/frontend/src/features/chat/ChannelView.tsx @@ -657,11 +657,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. 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. From 61b154b54c5d8daf153c295d1fca8ec590394031 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 15:03:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?refactor(frontend):=20one=20glyph=20per=20a?= =?UTF-8?q?ction=20=E2=80=94=20disambiguate=20the=20overloaded=20+?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `+` (lucide Plus) meant six different actions across the app; on the Workbench file panel it drew both create-file and add-to-context, so the same glyph did two unrelated things on one surface. Every create use of Plus is already mutually consistent (Plus = create a new resource). The only divergent family is add-to-context, which mixed Plus (3 sites) and Paperclip (1 site). Move it onto its own glyph: - add-to-context → MessageSquarePlus ("add to your next message"): the shared AttachContextButton, the suggested-context chip, the composer "Add context" menu, and the divergent Paperclip button in RemoteWorkspaceDialog. - Paperclip stays reserved for "attach/upload a file" (composer, channel files); Plus now means only "create"; FolderPlus (folder-rooted session), UserPlus (add person), TextQuote (ranged passage) unchanged. Documents the convention in DESIGN.md §2.18 + two anti-pattern entries so it stops drifting. Verified live on the kind stack: Workbench "+ New" is Plus (create), the file-toolbar add-to-context is the message-bubble glyph, the composer's Attach-file paperclip and Add-context button are now visibly distinct; clicking add-to-context still adds a ContextPickBar chip. typecheck clean, zero console errors. Co-Authored-By: Claude Fable 5 --- frontend/DESIGN.md | 26 +++++++++++++++++++ .../features/chat/RemoteWorkspaceDialog.tsx | 4 +-- .../features/chat/context/ContextPickBar.tsx | 8 +++--- 3 files changed, 32 insertions(+), 6 deletions(-) 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/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/context/ContextPickBar.tsx b/frontend/src/features/chat/context/ContextPickBar.tsx index aa6d9467..3a9c7067 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, @@ -146,7 +146,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 ? : } ); } @@ -190,7 +190,7 @@ export function ContextPickBar({ > {sg.label} - + {open && ( From 9d735f9554822430728009f7608efb1b42a63e95 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 15:34:24 +0800 Subject: [PATCH 3/3] feat(composer): browse Workbench/Workspace from Add-context, jumpable chips, session-aware model chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three composer improvements to the context/session flow: - Add-context menu gains a "Browse & attach" section: "Workbench files…" and "Workspace files…" open the respective surface so you can pick a file to attach (each marked with ↗). Previously the menu only offered the four channel reads and told you to go find the panels yourself. - Pending context chips for a Workbench file (fs.read) or a bot's workspace file (workspace.read) get a ↗ that jumps to where the resource lives — the Workbench focused on the file, or the Remote workspace at that path. Reuses the existing wbTarget / wsInit deep-link plumbing. - The composer model chip now follows the pinned session: selecting a session narrows the chip to that session's single owning bot and shows its model (e.g. "Opus") instead of the generic "Model · 4 bots" — because a pinned session routes to exactly one bot, ignoring @mentions. SessionChip.onChange now reports the session's botId for this. Verified live on the kind stack: Browse rows open the surfaces; a review.md chip's ↗ reopens the Workbench focused on it; pinning a session flips "Model · 4 bots" → "Opus". typecheck clean, zero console errors. Co-Authored-By: Claude Fable 5 --- frontend/src/features/chat/ChannelView.tsx | 69 +++++++++++++--- frontend/src/features/chat/SessionChip.tsx | 12 +-- .../features/chat/context/ContextPickBar.tsx | 78 ++++++++++++++++++- 3 files changed, 141 insertions(+), 18 deletions(-) diff --git a/frontend/src/features/chat/ChannelView.tsx b/frontend/src/features/chat/ChannelView.tsx index 3bf2551c..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); @@ -712,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), []); @@ -728,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 @@ -1331,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/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 3a9c7067..8fd225e6 100644 --- a/frontend/src/features/chat/context/ContextPickBar.tsx +++ b/frontend/src/features/chat/context/ContextPickBar.tsx @@ -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. */ @@ -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 }); @@ -207,6 +229,7 @@ export function ContextPickBar({ {items.map((it) => { const Icon = KIND_ICON[it.kind]; + const jumpTo = onJumpToSource && jumpTargetOf(it); return ( {it.label} + {jumpTo && ( + + )} @@ -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.

)}