From 1f6ad7bd39059a6f85541301153bb14471223570 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 22:29:08 +0000 Subject: [PATCH 1/2] fix(rockbot): RockBot never shows up as "No project" RockBot keeps its chats in the scratch folder only so its agent never runs in a repository, but that folder leaked everywhere: a "No project" icon on the rail and in the sidebar that seemed empty, the dashed folder on a RockBot chat's tab, "No project" in its hover card, search rows and palette entry, its header's project tools and terminal, mobile's terminal menu and project filter, and Settings > Archived. A scratch folder holding only RockBot chats (no live thread or unsent draft of the user's) now stays off the rail, the sidebar and the mobile project filter, and comes back as soon as the user starts a thread there. A RockBot chat's tab shows the RockBot glyph, its rows and palette entry say RockBot, its header drops the folder's project tools and terminal, mobile's terminal menu skips it, and archived chats are filed under RockBot. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015vtY45fQgQyf2XMrken3Ca --- .../src/features/home/HomeRouteScreen.tsx | 20 ++++-- .../src/features/home/homeTerminalTargets.ts | 5 +- apps/web/src/components/ChatView.tsx | 14 ++-- apps/web/src/components/CommandPalette.tsx | 7 +- apps/web/src/components/Sidebar.tsx | 20 ++++-- apps/web/src/components/chat/ChatHeader.tsx | 12 +++- .../components/settings/SettingsPanels.tsx | 30 ++++++-- .../sidebar/useSidebarProjectGroups.ts | 22 +++++- docs/internals/viewcode.md | 6 +- docs/user/rockbot.md | 3 +- .../client-runtime/src/state/rockbot.test.ts | 68 +++++++++++++++++++ packages/client-runtime/src/state/rockbot.ts | 27 ++++++++ 12 files changed, 205 insertions(+), 29 deletions(-) create mode 100644 packages/client-runtime/src/state/rockbot.test.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 83c18fcebf..ae37685763 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -31,6 +31,7 @@ import { useThreadListActions } from "./useThreadListActions"; import { HomeScopeFilters } from "./HomeScopeFilters"; import type { HomeHeaderProps } from "./HomeHeader.types"; import { useRockBotMenu } from "../rockbot/useRockBotMenu"; +import { holdsOnlyRockBot } from "@t3tools/client-runtime/state/rockbot"; /* ─── Route screen ───────────────────────────────────────────────────── */ @@ -104,10 +105,21 @@ export function HomeRouteScreen() { threads, pendingTasks, projectSortOrder: listOptions.projectSortOrder, - }).map((scope) => ({ - key: scope.key, - label: scope.title, - })), + }) + // The scratch folder RockBot alone uses is RockBot's, opened from its header button. + .filter((scope) => { + if (!scope.projects.some((project) => project.isScratch)) return true; + const members = new Set( + scope.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`), + ); + if (pendingTasks.some((task) => members.has(`${task.environmentId}:${task.projectId}`))) + return true; + return !holdsOnlyRockBot(members, threads, (thread) => thread.kind); + }) + .map((scope) => ({ + key: scope.key, + label: scope.title, + })), [ listOptions.projectGroupingMode, listOptions.projectSortOrder, diff --git a/apps/mobile/src/features/home/homeTerminalTargets.ts b/apps/mobile/src/features/home/homeTerminalTargets.ts index 330d41768e..850470242c 100644 --- a/apps/mobile/src/features/home/homeTerminalTargets.ts +++ b/apps/mobile/src/features/home/homeTerminalTargets.ts @@ -1,6 +1,6 @@ +import { isRockBot } from "@t3tools/client-runtime/state/rockbot"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/viewcodeThread"; - import { getThreadSortTimestamp } from "@t3tools/client-runtime/viewcodeThreadSort"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; @@ -38,7 +38,8 @@ export function buildHomeTerminalTargets(input: { { readonly thread: EnvironmentThreadShell; readonly timestamp: number } >(); for (const thread of input.threads) { - if (thread.archivedAt !== null) continue; + // A RockBot chat runs in the scratch folder only to stay out of every repository. + if (thread.archivedAt !== null || isRockBot(thread)) continue; const scopeKey = scopeKeyByProjectKey.get( scopedProjectKey(thread.environmentId, thread.projectId), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 82f5dc70a5..9b6b5f8997 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1855,6 +1855,9 @@ export default function ChatView(props: ChatViewProps) { ? scopeProjectRef(activeThreadEnvironmentId, activeThreadProjectId) : null; const activeProject = useProject(activeProjectRef); + // RockBot chats live in the scratch folder only to keep their agent out of every + // repository; the header never offers that folder's project tools. + const isRockBotChat = serverThread !== null && isRockBot(serverThread.source); const activeProjectSettings = resolveProjectSettings( settings, activeProject?.id ?? null, @@ -8055,7 +8058,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThread.id} isServerThread={isServerThread} activeThreadTitle={activeThread.title} - activeProject={activeProject ?? null} + activeProject={isRockBotChat ? null : (activeProject ?? null)} + isRockBot={isRockBotChat} parentThreadLink={parentThreadLink} onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} @@ -8063,8 +8067,8 @@ export default function ChatView(props: ChatViewProps) { actions={
- {serverThread !== null && isRockBot(serverThread.source) ? ( + {isRockBotChat ? ( ) : ( countSidebarAttention(visibleThreads, isThreadUnseen, agentRecordOf))(); + // A RockBot chat sits in the scratch folder only to keep its agent out of every + // repository, so rows name RockBot rather than "No project". + const projectOfThread = (thread: SidebarThreadSummary) => + thread.kind === "orchestrator" + ? null + : (projectByKey.get(physicalProjectKeyOf(thread)) ?? null); + const projectNameOfThread = (thread: SidebarThreadSummary) => + thread.kind === "orchestrator" + ? "RockBot" + : (projectDisplayNameByKey.get(physicalProjectKeyOf(thread)) ?? null); // RockBot's chats stand in for the project tree while one of them is open. const rockBotChats = (() => rockBotChatsForRoute(threads, routeThreadRef))(); const openRockBot = useOpenRockBot(rockBotChats?.[0]?.environmentId ?? null); @@ -3690,8 +3700,8 @@ export default function Sidebar() { currentEnvironmentId={primaryEnvironmentId} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} environmentMachine={environmentMachineById.get(thread.environmentId) ?? "server"} - project={projectByKey.get(physicalProjectKeyOf(thread)) ?? null} - projectDisplayName={projectDisplayNameByKey.get(physicalProjectKeyOf(thread)) ?? null} + project={projectOfThread(thread)} + projectDisplayName={projectNameOfThread(thread)} providerEntryByInstanceId={ providerEntriesByEnvironment.get(thread.environmentId) ?? EMPTY_PROVIDER_ENTRIES } @@ -3974,10 +3984,8 @@ export default function Sidebar() { key={threadKey} thread={thread} nowMinute={nowMinute} - project={projectByKey.get(physicalProjectKeyOf(thread)) ?? null} - projectDisplayName={ - projectDisplayNameByKey.get(physicalProjectKeyOf(thread)) ?? null - } + project={projectOfThread(thread)} + projectDisplayName={projectNameOfThread(thread)} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} environmentMachine={ environmentMachineById.get(thread.environmentId) ?? "server" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 34add81217..14279d09cf 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,6 +5,7 @@ import { type ThreadGoal, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { BotIcon } from "lucide-react"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { isAtomCommandInterrupted, @@ -45,6 +46,8 @@ interface ChatHeaderProps { onRunThreadGoal?: () => void; isServerThread: boolean; activeProject: EnvironmentProject | null; + /** A RockBot chat: its tab carries RockBot's glyph, since its folder names no project. */ + isRockBot?: boolean; parentThreadLink: { threadId: ThreadId; title: string } | null; onOpenThread: (threadId: ThreadId) => void; rightPanelOpen: boolean; @@ -81,6 +84,7 @@ export const ChatHeader = memo(function ChatHeader({ onRunThreadGoal, isServerThread, activeProject, + isRockBot = false, rightPanelOpen, actions, toolbarControlCount, @@ -236,9 +240,11 @@ export const ChatHeader = memo(function ChatHeader({ } }; const activeTabExtras: ActiveTabExtras = { - ...(activeProject - ? { glyph: } - : {}), + ...(isRockBot + ? { glyph: } + : activeProject + ? { glyph: } + : {}), ...(renamingTitle === null ? {} : { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5933495800..8b0058fe4c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -5,7 +5,14 @@ import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; import { Spinner } from "~/components/ui/spinner"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; -import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; +import { + ArchiveIcon, + ArchiveX, + BotIcon, + CheckIcon, + ChevronRightIcon, + SettingsIcon, +} from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react"; @@ -1436,8 +1443,7 @@ export function AppearanceSettingsPanel() {
} resetAction={ - settings.panelAnimationMs !== - DEFAULT_UNIFIED_SETTINGS.panelAnimationMs ? ( + settings.panelAnimationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationMs ? ( @@ -3441,12 +3447,26 @@ export function ArchivedThreadsPanel() { readonly project: (typeof archivedProjects)[number]; readonly title: string; readonly threads: Array<(typeof threads)[number]>; + readonly rockBot?: true; } >(); for (const project of archivedProjects) { const projectThreads: Array<(typeof threads)[number]> = []; for (const thread of threads) { if (thread.projectId === project.id && thread.environmentId === project.environmentId) { + // RockBot chats run in the scratch folder but are filed under RockBot. + if (thread.source.kind === "orchestrator") { + const rockBot = groups.get("rockbot"); + if (rockBot) rockBot.threads.push(thread); + else + groups.set("rockbot", { + project, + title: "RockBot", + threads: [thread], + rockBot: true, + }); + continue; + } projectThreads.push(thread); } } @@ -3558,12 +3578,12 @@ export function ArchivedThreadsPanel() { /> ) : ( - archivedGroups.map(({ key, project, title, threads: projectThreads }, index) => ( + archivedGroups.map(({ key, project, title, threads: projectThreads, rockBot }, index) => ( } + icon={rockBot ? : } > {projectThreads.map((thread) => ( const unsortedGroups = useSidebarProjectSnapshots(); const threads = useThreadShells(); const sortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - return (() => sortLogicalProjectsForSidebar(unsortedGroups, threads, sortOrder))(); + // An unsent draft keeps its folder, so a new "No project" chat shows while it is typed. + const draftProjectKeys = useComposerDraftStore((store) => + Object.values(store.draftThreadsByThreadKey) + .flatMap((draft) => (draft.promotedTo == null ? [projectRefKey(draft)] : [])) + .toSorted() + .join("\n"), + ); + return (() => { + const drafted = new Set(draftProjectKeys.split("\n")); + // The scratch folder RockBot alone uses is RockBot's, reached from its rail button. + const groups = unsortedGroups.filter((group) => { + if (!group.isScratch) return true; + const members = new Set(group.memberProjectRefs.map(projectRefKey)); + if ([...members].some((key) => drafted.has(key))) return true; + return !holdsOnlyRockBot(members, threads, (thread) => thread.source.kind); + }); + return sortLogicalProjectsForSidebar(groups, threads, sortOrder); + })(); } diff --git a/docs/internals/viewcode.md b/docs/internals/viewcode.md index 11f35cce4a..ad8bbb34bd 100644 --- a/docs/internals/viewcode.md +++ b/docs/internals/viewcode.md @@ -253,7 +253,11 @@ inside a repository, borrowing the latest project only where Scratch is unavaila and start on the latest thread's model. Project lists hide them: the web sidebar swaps its project tree for the chat list while one is open, the rail lights RockBot instead of a folder, and a folder pick leaves RockBot. Settlement candidates exclude -them. +them. Scratch is only their storage, so nothing names it for them: a chat's tab, +rows, palette entry and mobile header say RockBot, its header offers no project +tools, and a Scratch folder holding only RockBot chats (no live thread or draft of +the user's) is left out of the rail, sidebar and mobile project filter +(`holdsOnlyRockBot`). Its safety is the server's, not its prompt's. `McpToolAccess` refuses every writing tool to an orchestrator caller except the `sendsForUser` ones (`t3_thread_send`, diff --git a/docs/user/rockbot.md b/docs/user/rockbot.md index 5066ce7191..711185b5a0 100644 --- a/docs/user/rockbot.md +++ b/docs/user/rockbot.md @@ -19,7 +19,8 @@ in its composer like any thread. Each computer you connect has its own RockBot. While a RockBot chat is open, the sidebar lists your RockBot chats in place of your projects, newest first. Start another with the pencil next to **RockBot**, or **New RockBot chat** in the command palette. Rename or archive them like any thread; archived -chats are under **Settings → Archived**. Click a project on the rail to go back to your +chats are under **RockBot** in **Settings → Archived**. RockBot chats never appear under +**No project**, which only lists threads you start without a project. Click a project on the rail to go back to your work. On mobile (iOS and Android), the sparkle button on Home opens a menu with **New chat** diff --git a/packages/client-runtime/src/state/rockbot.test.ts b/packages/client-runtime/src/state/rockbot.test.ts new file mode 100644 index 0000000000..eaba5a117e --- /dev/null +++ b/packages/client-runtime/src/state/rockbot.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { holdsOnlyRockBot, rockBotChatsIn } from "./rockbot.ts"; + +const thread = ( + id: string, + extra: { + kind?: string | null; + projectId?: string; + archivedAt?: string | null; + updatedAt?: string; + } = {}, +) => ({ + environmentId: "local", + id, + projectId: "scratch", + kind: null, + archivedAt: null, + updatedAt: "2026-10-20T10:00:00Z", + ...extra, +}); +const scratch = new Set(["local:scratch"]); +const kindOf = (value: { kind?: string | null }) => value.kind; + +describe("holdsOnlyRockBot", () => { + it("claims the scratch folder while RockBot chats are all it holds", () => { + expect( + holdsOnlyRockBot( + scratch, + [ + thread("rockbot", { kind: "orchestrator" }), + thread("old", { archivedAt: "2026-10-19T10:00:00Z" }), + thread("work", { projectId: "viewcode" }), + ], + kindOf, + ), + ).toBe(true); + }); + + it("gives it back once the user has a live thread there, and never claims an empty one", () => { + expect( + holdsOnlyRockBot( + scratch, + [thread("rockbot", { kind: "orchestrator" }), thread("mine")], + kindOf, + ), + ).toBe(false); + expect(holdsOnlyRockBot(scratch, [thread("work", { projectId: "viewcode" })], kindOf)).toBe( + false, + ); + }); +}); + +describe("rockBotChatsIn", () => { + it("lists one environment's live RockBot chats, newest first", () => { + expect( + rockBotChatsIn( + [ + thread("rockbot", { kind: "orchestrator", updatedAt: "2026-10-20T09:00:00Z" }), + thread("rockbot:2", { kind: "orchestrator", updatedAt: "2026-10-20T12:00:00Z" }), + thread("rockbot:gone", { kind: "orchestrator", archivedAt: "2026-10-20T13:00:00Z" }), + thread("work"), + ], + "local", + ).map((chat) => chat.id), + ).toEqual(["rockbot:2", "rockbot"]); + }); +}); diff --git a/packages/client-runtime/src/state/rockbot.ts b/packages/client-runtime/src/state/rockbot.ts index cac4abde6d..26700ad73c 100644 --- a/packages/client-runtime/src/state/rockbot.ts +++ b/packages/client-runtime/src/state/rockbot.ts @@ -32,6 +32,33 @@ export function rockBotChatsIn< .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } +/** + * Whether a project folder holds RockBot chats and nothing the user started there. + * RockBot keeps its chats in the scratch folder only so its agent never runs in a + * repository, so such a folder is RockBot's, and lists hide it rather than show an + * empty "No project". Archived threads and side chats do not count. + */ +export function holdsOnlyRockBot( + memberProjectKeys: ReadonlySet, + threads: ReadonlyArray, + kindOf: (thread: T) => string | null | undefined, +): boolean { + let rockBot = false; + for (const thread of threads) { + if (!memberProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) continue; + const kind = kindOf(thread); + if (kind === "orchestrator") rockBot = true; + else if (kind == null && thread.archivedAt === null) return false; + } + return rockBot; +} + +interface RockBotFolderThread { + readonly environmentId: string; + readonly projectId: string; + readonly archivedAt: string | null; +} + export function createRockBotEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { From 623ed51b0a7507ced69c722e3f1e42acad106ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 09:05:06 +0000 Subject: [PATCH 2/2] fix(rockbot): keep No project for everyone's own chats Hiding the No project folder whenever it held only RockBot chats also took away the one-click way to start a chat without a project, which users rely on to ask the agent anything. No project is back on the rail, sidebar and mobile filter as before; RockBot chats still never show inside it or name it, and RockBot activity no longer reorders the folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015vtY45fQgQyf2XMrken3Ca --- .../src/features/home/HomeRouteScreen.tsx | 24 ++++--------- .../sidebar/useSidebarProjectGroups.ts | 30 +++++----------- docs/internals/viewcode.md | 9 +++-- docs/user/rockbot.md | 2 +- .../client-runtime/src/state/rockbot.test.ts | 36 +------------------ packages/client-runtime/src/state/rockbot.ts | 27 -------------- 6 files changed, 22 insertions(+), 106 deletions(-) diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index ae37685763..c15f1b5fca 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -31,7 +31,7 @@ import { useThreadListActions } from "./useThreadListActions"; import { HomeScopeFilters } from "./HomeScopeFilters"; import type { HomeHeaderProps } from "./HomeHeader.types"; import { useRockBotMenu } from "../rockbot/useRockBotMenu"; -import { holdsOnlyRockBot } from "@t3tools/client-runtime/state/rockbot"; +import { isRockBot } from "@t3tools/client-runtime/state/rockbot"; /* ─── Route screen ───────────────────────────────────────────────────── */ @@ -102,24 +102,14 @@ export function HomeRouteScreen() { environmentId: selectedEnvironmentId, projectGroupingMode: listOptions.projectGroupingMode, }), - threads, + // RockBot chats live in No project only as storage; they must not reorder it. + threads: threads.filter((thread) => !isRockBot(thread)), pendingTasks, projectSortOrder: listOptions.projectSortOrder, - }) - // The scratch folder RockBot alone uses is RockBot's, opened from its header button. - .filter((scope) => { - if (!scope.projects.some((project) => project.isScratch)) return true; - const members = new Set( - scope.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`), - ); - if (pendingTasks.some((task) => members.has(`${task.environmentId}:${task.projectId}`))) - return true; - return !holdsOnlyRockBot(members, threads, (thread) => thread.kind); - }) - .map((scope) => ({ - key: scope.key, - label: scope.title, - })), + }).map((scope) => ({ + key: scope.key, + label: scope.title, + })), [ listOptions.projectGroupingMode, listOptions.projectSortOrder, diff --git a/apps/web/src/components/sidebar/useSidebarProjectGroups.ts b/apps/web/src/components/sidebar/useSidebarProjectGroups.ts index 811b2f14f2..6991d101ba 100644 --- a/apps/web/src/components/sidebar/useSidebarProjectGroups.ts +++ b/apps/web/src/components/sidebar/useSidebarProjectGroups.ts @@ -8,9 +8,7 @@ import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environmen import { useProjects, useThreadShells } from "../../state/entities"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../../uiStateStore"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "../Sidebar.logic"; -import { holdsOnlyRockBot } from "@t3tools/client-runtime/state/rockbot"; -import { useComposerDraftStore } from "../../composerDraftStore"; -import { projectRefKey } from "./projectSwitch.logic"; +import { isRockBot } from "@t3tools/client-runtime/state/rockbot"; /** * The sidebar's project folders with their names and members, not yet in sidebar order. For @@ -56,22 +54,12 @@ export function useSidebarProjectGroups(): ReadonlyArray const unsortedGroups = useSidebarProjectSnapshots(); const threads = useThreadShells(); const sortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); - // An unsent draft keeps its folder, so a new "No project" chat shows while it is typed. - const draftProjectKeys = useComposerDraftStore((store) => - Object.values(store.draftThreadsByThreadKey) - .flatMap((draft) => (draft.promotedTo == null ? [projectRefKey(draft)] : [])) - .toSorted() - .join("\n"), - ); - return (() => { - const drafted = new Set(draftProjectKeys.split("\n")); - // The scratch folder RockBot alone uses is RockBot's, reached from its rail button. - const groups = unsortedGroups.filter((group) => { - if (!group.isScratch) return true; - const members = new Set(group.memberProjectRefs.map(projectRefKey)); - if ([...members].some((key) => drafted.has(key))) return true; - return !holdsOnlyRockBot(members, threads, (thread) => thread.source.kind); - }); - return sortLogicalProjectsForSidebar(groups, threads, sortOrder); - })(); + // RockBot chats live in No project only as storage; chatting with RockBot must not + // move that folder up. + return (() => + sortLogicalProjectsForSidebar( + unsortedGroups, + threads.filter((thread) => !isRockBot(thread.source)), + sortOrder, + ))(); } diff --git a/docs/internals/viewcode.md b/docs/internals/viewcode.md index ad8bbb34bd..ac66067e2d 100644 --- a/docs/internals/viewcode.md +++ b/docs/internals/viewcode.md @@ -253,11 +253,10 @@ inside a repository, borrowing the latest project only where Scratch is unavaila and start on the latest thread's model. Project lists hide them: the web sidebar swaps its project tree for the chat list while one is open, the rail lights RockBot instead of a folder, and a folder pick leaves RockBot. Settlement candidates exclude -them. Scratch is only their storage, so nothing names it for them: a chat's tab, -rows, palette entry and mobile header say RockBot, its header offers no project -tools, and a Scratch folder holding only RockBot chats (no live thread or draft of -the user's) is left out of the rail, sidebar and mobile project filter -(`holdsOnlyRockBot`). +them. Scratch is only their storage, and No project stays the user's own place for +chats without a repository: a RockBot chat's tab, rows, palette entry and mobile +header say RockBot, its header offers no project tools, and RockBot activity does +not reorder the No project folder. Its safety is the server's, not its prompt's. `McpToolAccess` refuses every writing tool to an orchestrator caller except the `sendsForUser` ones (`t3_thread_send`, diff --git a/docs/user/rockbot.md b/docs/user/rockbot.md index 711185b5a0..a369ec27d3 100644 --- a/docs/user/rockbot.md +++ b/docs/user/rockbot.md @@ -20,7 +20,7 @@ While a RockBot chat is open, the sidebar lists your RockBot chats in place of y projects, newest first. Start another with the pencil next to **RockBot**, or **New RockBot chat** in the command palette. Rename or archive them like any thread; archived chats are under **RockBot** in **Settings → Archived**. RockBot chats never appear under -**No project**, which only lists threads you start without a project. Click a project on the rail to go back to your +**No project**, which stays the place for chats you start without a project. Click a project on the rail to go back to your work. On mobile (iOS and Android), the sparkle button on Home opens a menu with **New chat** diff --git a/packages/client-runtime/src/state/rockbot.test.ts b/packages/client-runtime/src/state/rockbot.test.ts index eaba5a117e..8cc5d062fc 100644 --- a/packages/client-runtime/src/state/rockbot.test.ts +++ b/packages/client-runtime/src/state/rockbot.test.ts @@ -1,56 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { holdsOnlyRockBot, rockBotChatsIn } from "./rockbot.ts"; +import { rockBotChatsIn } from "./rockbot.ts"; const thread = ( id: string, extra: { kind?: string | null; - projectId?: string; archivedAt?: string | null; updatedAt?: string; } = {}, ) => ({ environmentId: "local", id, - projectId: "scratch", kind: null, archivedAt: null, updatedAt: "2026-10-20T10:00:00Z", ...extra, }); -const scratch = new Set(["local:scratch"]); -const kindOf = (value: { kind?: string | null }) => value.kind; - -describe("holdsOnlyRockBot", () => { - it("claims the scratch folder while RockBot chats are all it holds", () => { - expect( - holdsOnlyRockBot( - scratch, - [ - thread("rockbot", { kind: "orchestrator" }), - thread("old", { archivedAt: "2026-10-19T10:00:00Z" }), - thread("work", { projectId: "viewcode" }), - ], - kindOf, - ), - ).toBe(true); - }); - - it("gives it back once the user has a live thread there, and never claims an empty one", () => { - expect( - holdsOnlyRockBot( - scratch, - [thread("rockbot", { kind: "orchestrator" }), thread("mine")], - kindOf, - ), - ).toBe(false); - expect(holdsOnlyRockBot(scratch, [thread("work", { projectId: "viewcode" })], kindOf)).toBe( - false, - ); - }); -}); - describe("rockBotChatsIn", () => { it("lists one environment's live RockBot chats, newest first", () => { expect( diff --git a/packages/client-runtime/src/state/rockbot.ts b/packages/client-runtime/src/state/rockbot.ts index 26700ad73c..cac4abde6d 100644 --- a/packages/client-runtime/src/state/rockbot.ts +++ b/packages/client-runtime/src/state/rockbot.ts @@ -32,33 +32,6 @@ export function rockBotChatsIn< .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } -/** - * Whether a project folder holds RockBot chats and nothing the user started there. - * RockBot keeps its chats in the scratch folder only so its agent never runs in a - * repository, so such a folder is RockBot's, and lists hide it rather than show an - * empty "No project". Archived threads and side chats do not count. - */ -export function holdsOnlyRockBot( - memberProjectKeys: ReadonlySet, - threads: ReadonlyArray, - kindOf: (thread: T) => string | null | undefined, -): boolean { - let rockBot = false; - for (const thread of threads) { - if (!memberProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) continue; - const kind = kindOf(thread); - if (kind === "orchestrator") rockBot = true; - else if (kind == null && thread.archivedAt === null) return false; - } - return rockBot; -} - -interface RockBotFolderThread { - readonly environmentId: string; - readonly projectId: string; - readonly archivedAt: string | null; -} - export function createRockBotEnvironmentAtoms( runtime: Atom.AtomRuntime, ) {