From eafdb14b6398aff523eb46689e1b12c1da26ede5 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Thu, 12 Nov 2026 21:36:13 -0800 Subject: [PATCH 1/7] Show disabled plugin chats in Solo Present existing chats from an explicitly disabled plugin in the normal Solo inventory. Keep their region labels and filing guards dormant so re-enabling restores plugin-owned placement, while genuine recovery states remain in recovery. Co-authored-by: Codex Signed-off-by: Kyle Zemek --- docs/buzz-help.md | 4 +- docs/solo-regions.md | 17 ++- src/bundled/channels/SoloChatMenu.tsx | 4 +- src/bundled/channels/SoloHeading.tsx | 6 +- src/bundled/channels/SoloWorkspaces.tsx | 9 +- .../channel-navigation/SoloInventory.tsx | 1 + .../channel-navigation/SoloRegions.test.tsx | 127 ++++++++++++++++ .../channel-navigation/SoloRegions.tsx | 4 +- .../solo-navigation-model.ts | 36 +++-- .../solo-navigation-react.ts | 6 +- .../solo-navigation-testing.ts | 2 +- .../solo-navigation.test.ts | 137 +++++++++++++++++- .../channel-navigation/solo-navigation.ts | 59 ++++++-- src/features/solo/WorkspaceControl.tsx | 2 +- tests/browser/solo-region-state.spec.mjs | 115 ++++++++++++++- 15 files changed, 485 insertions(+), 44 deletions(-) diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 77e6471569..97fdfe975e 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -892,7 +892,9 @@ and are asked to sign in again in Buzz. starred channel in Starred until you unstar it. - **Plugin channel rows**: a plugin can open a channel from its Solo row and keep that row selected. The sidebar then keeps its scope, scroll position, and open - sections. Opening the channel from Team follows the usual Team navigation. + sections. When a plugin is explicitly disabled, its existing chats appear as + ordinary Solo chats; enabling it again returns them to the plugin's rows when + available. Opening the channel from Team follows the usual Team navigation. - **Side panels stay with their conversation**: a tab or panel you open beside a conversation, such as a thread, **Canvas**, a huddle, or Todos, stays open for that conversation when you go to another conversation or page and come back, diff --git a/docs/solo-regions.md b/docs/solo-regions.md index 43adcc8565..222f79a714 100644 --- a/docs/solo-regions.md +++ b/docs/solo-regions.md @@ -5,12 +5,17 @@ implementation. The public types are exported from `@buzz/author`. The service i created with the relay in `src/app/services.ts`; plugins register sources through their tracked Cordis context. -The host derives `buzz.region::` from the caller. Labels own -placement, even when a plugin is off, loading, unavailable, or failed. The host -uses one projection for the live list, recovery list, inventory exclusion, header -chip, and personal filing guards. Unknown channel types do not imply Solo. -Existing personal workspace assignments remain stored while they are dormant. -Label edits preserve region labels. There is no public remove operation. +The host derives `buzz.region::` from the caller. A ready +catalog that explicitly disables a plugin shows its eligible labeled chats in +the ordinary Solo inventory while retaining their labels and filing guards. +Their region associations stay dormant and are reclaimed if the plugin is +enabled again. A missing source alone does not imply that a plugin is disabled: +loading, unavailable, failed, not-installed, and unknown states remain in the +recovery placement. The host uses one projection for region presentation, +inventory exclusion, and personal filing guards. Unknown channel types do not +imply Solo. Existing personal workspace assignments remain stored while those +assignments are dormant. Label edits preserve region labels. There is no public +remove operation. Each bind has a fresh incarnation. Reconnect and Retry abort the previous bind; old handles, menus, state writes, and reveal subscribers cannot act in the new diff --git a/src/bundled/channels/SoloChatMenu.tsx b/src/bundled/channels/SoloChatMenu.tsx index 452e9e46a1..41edae4b1c 100644 --- a/src/bundled/channels/SoloChatMenu.tsx +++ b/src/bundled/channels/SoloChatMenu.tsx @@ -123,7 +123,7 @@ export function SoloChatMenuItems({ Chat settings {active && - !regions.excluded.has(channel.id) && + !regions.managed.has(channel.id) && onMove && onNewWorkspace && ( @@ -159,7 +159,7 @@ export function SoloChatMenuItems({ )} {active && - !regions.excluded.has(channel.id) && + !regions.managed.has(channel.id) && onConvert && canConvertToChannel(session, channel) && ( diff --git a/src/bundled/channels/SoloHeading.tsx b/src/bundled/channels/SoloHeading.tsx index 7e2e29d4ee..30f22c1407 100644 --- a/src/bundled/channels/SoloHeading.tsx +++ b/src/bundled/channels/SoloHeading.tsx @@ -110,8 +110,10 @@ export function SoloToolbar({ () => chatMenus?.snapshot(), ); const regions = useSoloNavigation(session); - const region = regions.regions.find((region) => - region.channels.some((item) => item.id === channel.id), + const region = regions.regions.find( + (region) => + region.reason !== "disabled" && + region.channels.some((item) => item.id === channel.id), ); const actionsTrigger = useRef(null); // Each dialog returns focus to the control that opened it. diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index 648aeb4734..63bdb75c92 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -88,6 +88,7 @@ export function SoloWorkspaces({ navigator: navigationController, scope, chats, + dormantAssignments, renderChat, renderChatMenu, onNew, @@ -105,6 +106,8 @@ export function SoloWorkspaces({ navigator?: Navigation | undefined; scope: string; chats: readonly ChatListItem[]; + /** Existing assignments stay stored but do not place source-managed chats. */ + dormantAssignments?: ReadonlySet | undefined; sidebarRows?: readonly RegisteredSidebarRows[] | undefined; renderChatMenu?: ((channelId: string) => ReactNode) | undefined; /** A stable function lets a chat's row skip renders when nothing it shows changed. */ @@ -240,6 +243,8 @@ export function SoloWorkspaces({ }>(); const groups = snapshot.data?.sections ?? []; const assigned = snapshot.data?.assignments ?? {}; + const assignmentFor = (item: ChatListItem) => + dormantAssignments?.has(item.id) ? undefined : assigned[item.id]; const ids = new Set(groups.map((group) => group.id)); const draftProject = draft ? ids.has(draft.projectId ?? "") @@ -258,12 +263,12 @@ export function SoloWorkspaces({ const projects = [ ...groups.map((group) => ({ ...group, - rows: chats.filter((item) => assigned[item.id] === group.id), + rows: chats.filter((item) => assignmentFor(item) === group.id), })), { id: "", name: "Chats", - rows: chats.filter((item) => !ids.has(assigned[item.id] ?? "")), + rows: chats.filter((item) => !ids.has(assignmentFor(item) ?? "")), }, ]; // A collapse request takes effect after the editor closes. Keep its draft and diff --git a/src/features/channel-navigation/SoloInventory.tsx b/src/features/channel-navigation/SoloInventory.tsx index 4a993ab092..3f38aa710a 100644 --- a/src/features/channel-navigation/SoloInventory.tsx +++ b/src/features/channel-navigation/SoloInventory.tsx @@ -347,6 +347,7 @@ export function SoloInventory({ )} , target: OpenTarget = { version: 1, kind: "home" }, wrap: (children: ReactNode) => ReactNode = (children) => children, + fallback?: ReactNode, ) { const open = vi.fn(async () => ({ status: "opened" as const })); const retryReveal = vi.fn(); @@ -81,6 +84,7 @@ function mount( scope={h.scope} target={target} navigator={navigator} + fallback={fallback} sidebar={{ list, collapsed, @@ -1306,6 +1310,129 @@ it("keeps recovery chats reachable while distinguishing installed, unknown and n expect(screen.queryByText(/buzz\.region:/)).not.toBeInTheDocument(); }); +it("shows explicitly disabled region chats once in the ordinary Solo inventory", async () => { + const h = fixture(); + const saved = { + sections: [{ id: "personal", name: "Personal", order: 0 }], + assignments: { owned: "personal" }, + starred: [], + muted: [], + }; + const preferences = { status: "ready" as const, data: saved }; + Object.assign(h.session, { + soloPreferences: { + ...h.session.soloPreferences, + snapshot: () => preferences, + subscribe: () => () => {}, + }, + }); + const configuration: ConfigurationState = { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "Fixture", + plugins: [ + { + manifest: { id: "example", name: "Example", apiVersion: 1 }, + source: "external", + enabled: false, + revision: "one", + previous: null, + reloadable: false, + error: null, + }, + ], + }, + }; + const channel = h.session.channels + .list() + .channels.find((item) => item.id === "owned"); + assert(channel); + const target = { + version: 1, + kind: "conversation", + scope: { + communityOrigin: "https://fixture.invalid", + viewer: h.session.viewer ?? "", + }, + channelId: "owned", + } as const; + h.navigate(target); + h.plugins(configuration); + const ordinary = ( + + ); + mount( + h, + target, + (children) => ( + + {children} + {}} + onLifecycle={() => {}} + onMove={() => {}} + > + + + + ), + ordinary, + ); + + expect(screen.getByRole("button", { name: "Owned" })).toBeInTheDocument(); + expect( + document.querySelectorAll('[data-solo-channel-id="owned"]'), + ).toHaveLength(1); + expect( + document + .querySelector('[data-solo-project="personal"]') + ?.querySelector('[data-solo-channel-id="owned"]'), + ).toBeNull(); + expect( + document + .querySelector('[data-solo-project=""]') + ?.querySelector('[data-solo-channel-id="owned"]'), + ).not.toBeNull(); + expect(saved.assignments).toEqual({ owned: "personal" }); + expect( + screen.queryByText("The plugin for these chats is off."), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Work could not load")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open plugin settings" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "In Plugin chats" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Workspace: Personal" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Chat actions" }), + ).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Chat actions" })); + expect( + await screen.findByRole("menuitem", { name: "Chat settings" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: "Move to" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /convert/i }), + ).not.toBeInTheDocument(); +}); + it("uses shared populated menus for header choices and refuses retired menu callbacks", async () => { const h = fixture(); const select = vi.fn(); diff --git a/src/features/channel-navigation/SoloRegions.tsx b/src/features/channel-navigation/SoloRegions.tsx index bb5443b470..856a8c4ffa 100644 --- a/src/features/channel-navigation/SoloRegions.tsx +++ b/src/features/channel-navigation/SoloRegions.tsx @@ -98,9 +98,9 @@ export function SoloRegions({ const snapshot = useSoloNavigation(session); const service = soloNavigationFor(session); const regions = orderContributedSidebarSections( - // A hidden region draws nothing; recovery is never hidden. + // Hidden sources draw nothing; explicitly disabled chats use ordinary Solo. snapshot.regions - .filter((region) => !region.hidden) + .filter((region) => !region.hidden && region.reason !== "disabled") .map((region) => ({ key: `solo-region:${region.key}`, region, diff --git a/src/features/channel-navigation/solo-navigation-model.ts b/src/features/channel-navigation/solo-navigation-model.ts index 8bf1b9292f..680ba018ab 100644 --- a/src/features/channel-navigation/solo-navigation-model.ts +++ b/src/features/channel-navigation/solo-navigation-model.ts @@ -16,7 +16,13 @@ export type SoloRegion = Readonly<{ title: string; source?: RegisteredSoloSource; snapshot?: SoloRegionSnapshot; - reason?: "not-installed" | "off" | "loading" | "unavailable" | "failed"; + reason?: + | "not-installed" + | "off" + | "disabled" + | "loading" + | "unavailable" + | "failed"; channels: readonly ChannelSummary[]; live: ReadonlySet; /** A ready source keeps it out of the sidebar; its chats stay excluded. */ @@ -25,6 +31,7 @@ export type SoloRegion = Readonly<{ export type SoloPlacement = Readonly<{ regions: readonly SoloRegion[]; excluded: ReadonlySet; + managed: ReadonlySet; }>; /** One projection of the authorized roster, also consumed by every filing guard. */ @@ -37,6 +44,7 @@ export function projectSoloRegions( failed: boolean; }>[], title: (label: string, value?: string) => string, + disabledPluginIds: ReadonlySet = new Set(), ): SoloPlacement { const groups = new Map(); for (const source of sources) @@ -49,19 +57,27 @@ export function projectSoloRegions( groups.get(label)?.push(channel); } const excluded = new Set(); + const managed = new Set(); const regions = [...groups] .map(([label, rows]): SoloRegion => { const current = sources.find( (item) => soloRegionLabelOf(item.source) === label, ); - const reason = !current - ? "off" - : current.failed - ? "failed" - : current.snapshot?.status === "ready" - ? undefined - : (current.snapshot?.status ?? "loading"); - for (const row of rows) excluded.add(row.id); + const pluginId = label.slice(REGION_PREFIX.length).split(":", 1)[0] ?? ""; + const disabled = disabledPluginIds.has(pluginId); + const reason = disabled + ? "disabled" + : !current + ? "off" + : current.failed + ? "failed" + : current.snapshot?.status === "ready" + ? undefined + : (current.snapshot?.status ?? "loading"); + for (const row of rows) { + managed.add(row.id); + if (!disabled) excluded.add(row.id); + } return { key: current?.source.key ?? @@ -83,7 +99,7 @@ export function projectSoloRegions( (a.source?.order ?? 0) - (b.source?.order ?? 0) || a.key.localeCompare(b.key), ); - return { regions, excluded }; + return { regions, excluded, managed }; } function soloRegionLabelOf(source: RegisteredSoloSource) { diff --git a/src/features/channel-navigation/solo-navigation-react.ts b/src/features/channel-navigation/solo-navigation-react.ts index e971b33f13..c95960e4e7 100644 --- a/src/features/channel-navigation/solo-navigation-react.ts +++ b/src/features/channel-navigation/solo-navigation-react.ts @@ -5,7 +5,11 @@ import { type SoloNavigationSnapshot, } from "./solo-navigation"; -const empty: SoloNavigationSnapshot = { regions: [], excluded: new Set() }; +const empty: SoloNavigationSnapshot = { + regions: [], + excluded: new Set(), + managed: new Set(), +}; const noSubscription = () => () => {}; export function useSoloNavigation(session: RelaySession) { const reader = soloNavigationFor(session); diff --git a/src/features/channel-navigation/solo-navigation-testing.ts b/src/features/channel-navigation/solo-navigation-testing.ts index 152f2c7a4a..34c7585d1e 100644 --- a/src/features/channel-navigation/solo-navigation-testing.ts +++ b/src/features/channel-navigation/solo-navigation-testing.ts @@ -31,7 +31,7 @@ export function createSoloRegionFixture(viewer = "a".repeat(64)) { { manifest: { id: "example", name: "Example", apiVersion: 1 }, source: "external", - enabled: false, + enabled: true, revision: "one", previous: null, reloadable: false, diff --git a/src/features/channel-navigation/solo-navigation.test.ts b/src/features/channel-navigation/solo-navigation.test.ts index 652a7de119..a9ab1def65 100644 --- a/src/features/channel-navigation/solo-navigation.test.ts +++ b/src/features/channel-navigation/solo-navigation.test.ts @@ -2,8 +2,9 @@ import { createChannelDetails } from "../relay/channel-details"; import { keypair, signed } from "../relay/testing"; import type { RelayEvent } from "../relay/events"; import { createRelaySession } from "../relay/session"; -import { prepareSoloMove } from "./solo-navigation"; +import type { ConfigurationState } from "../../plugins/types"; import { createSoloRegionFixture } from "./solo-navigation-testing"; +import { prepareSoloMove } from "./solo-navigation"; import { afterEach, expect, it, vi } from "vitest"; function deferred() { @@ -25,6 +26,25 @@ function fixture() { cleanup.push(h.dispose); return h; } +const pluginConfiguration = (enabled: boolean): ConfigurationState => ({ + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "Fixture", + plugins: [ + { + manifest: { id: "example", name: "Example", apiVersion: 1 }, + source: "external", + enabled, + revision: "one", + previous: null, + reloadable: false, + error: null, + }, + ], + }, +}); it("keeps labeled channels in one live or recovery placement throughout registration and disposal", async () => { const h = fixture(); @@ -430,7 +450,118 @@ it.each([ }); }); -it.each(["starting", "failed", "disabled", "paused", "stale"] as const)( +it("shows explicitly disabled region chats in ordinary Solo and reclaims them when re-enabled", async () => { + const h = fixture(); + await h.register(); + const active = { + example: { revision: "one", status: "active" as const, error: null }, + }; + const before = h.session.channels + .list() + .channels.find((channel) => channel.id === "owned"); + expect([...h.service.snapshot().excluded]).toEqual(["owned", "shared"]); + + h.plugins(pluginConfiguration(false), active); + const disabled = h.service.snapshot(); + expect([...disabled.excluded]).toEqual([]); + expect([...disabled.managed]).toEqual(["owned", "shared"]); + expect(prepareSoloMove(h.session, "owned")).toBe( + "This chat is managed by Work.", + ); + expect(prepareSoloMove(h.session, "chat")).toBeUndefined(); + expect( + h.session.channels + .list() + .channels.find((channel) => channel.id === "owned"), + ).toEqual(before); + + h.plugins(pluginConfiguration(true), active); + const enabled = h.service.snapshot(); + expect([...enabled.excluded]).toEqual(["owned", "shared"]); + expect([...enabled.managed]).toEqual(["owned", "shared"]); +}); + +it("matches disabled plugins by exact id without misclassifying an uninstalled plugin", () => { + const h = fixture(); + h.roster([ + ...h.session.channels.list().channels, + { + id: "prefix", + name: "Prefix", + channelType: "solo", + creator: h.session.viewer, + labels: ["buzz.solo", "buzz.region:example-other:work"], + }, + ]); + h.plugins(pluginConfiguration(false)); + + const snapshot = h.service.snapshot(); + expect( + snapshot.regions.find((region) => region.label === label)?.reason, + ).toBe("disabled"); + expect( + snapshot.regions.find( + (region) => region.label === "buzz.region:example-other:work", + )?.reason, + ).toBe("not-installed"); + expect([...snapshot.excluded]).toEqual(["prefix"]); + expect([...snapshot.managed]).toEqual(["owned", "shared", "prefix"]); +}); + +it("keeps disabled chats ordinary through loading, re-enable, and reconnect", async () => { + const h = fixture(); + const active = { + example: { revision: "one", status: "active" as const, error: null }, + }; + h.plugins(pluginConfiguration(false), active); + expect(h.service.snapshot().regions[0]?.reason).toBe("disabled"); + expect([...h.service.snapshot().excluded]).toEqual([]); + + h.plugins({ status: "loading" }); + expect(h.service.snapshot().regions[0]?.reason).toBe("loading"); + expect([...h.service.snapshot().excluded]).toEqual(["owned", "shared"]); + + h.plugins(pluginConfiguration(true), active); + expect(h.service.snapshot().regions[0]?.reason).toBe("off"); + const source = await h.register(); + expect(h.service.snapshot().regions[0]?.reason).toBeUndefined(); + expect([...h.service.snapshot().excluded]).toEqual(["owned", "shared"]); + + await source.fiber.dispose(); + h.plugins(pluginConfiguration(false), active); + h.reconnect(); + expect(h.service.snapshot().regions[0]?.reason).toBe("disabled"); + expect([...h.service.snapshot().excluded]).toEqual([]); + expect([...h.service.snapshot().managed]).toEqual(["owned", "shared"]); +}); + +it("does not reveal disabled chats or replay a reveal queued before disabling", async () => { + const h = fixture(); + await h.register(); + await h.navigate({ + version: 1, + kind: "conversation", + scope: { + communityOrigin: "https://fixture.invalid", + viewer: h.session.viewer ?? "", + }, + channelId: "owned", + }); + const listener = vi.fn(); + h.service.reveal("owned"); + h.plugins(pluginConfiguration(false), { + example: { revision: "one", status: "active", error: null }, + }); + h.service.reveal("owned"); + h.plugins(pluginConfiguration(true), { + example: { revision: "one", status: "active", error: null }, + }); + const stop = h.service.subscribeReveal(listener, true); + expect(listener).not.toHaveBeenCalled(); + stop(); +}); + +it.each(["starting", "failed", "paused", "stale"] as const)( "uses the installed plugin's current activation for recovery (%s)", (state) => { const h = fixture(); @@ -445,7 +576,7 @@ it.each(["starting", "failed", "disabled", "paused", "stale"] as const)( { manifest: { id: "example", name: "Example", apiVersion: 1 }, source: "external", - enabled: state !== "disabled", + enabled: true, revision: "one", previous: null, reloadable: false, diff --git a/src/features/channel-navigation/solo-navigation.ts b/src/features/channel-navigation/solo-navigation.ts index a792d75ca1..836b55a43b 100644 --- a/src/features/channel-navigation/solo-navigation.ts +++ b/src/features/channel-navigation/solo-navigation.ts @@ -12,7 +12,11 @@ import { parseOpenTarget, type JsonValue } from "../navigation/targets"; import type { PluginManager } from "../../plugins/manager"; import { relayPartition } from "../relay/partition"; import { ownSolo, soloLabels } from "../solo/labels"; -import { regionLabels, soloRegionLabel } from "../solo/region-labels"; +import { + REGION_PREFIX, + regionLabels, + soloRegionLabel, +} from "../solo/region-labels"; import { soloDraft, soloDraftTarget } from "./routes"; import { createSoloWorkspaces } from "./solo-workspaces"; import { @@ -67,7 +71,11 @@ export class SoloNavigationService extends Service implements SoloNavigation { private session: RelaySession | undefined; private connection: RelaySnapshot | undefined; private stopChannels: (() => void) | undefined; - private value: SoloNavigationSnapshot = { regions: [], excluded: new Set() }; + private value: SoloNavigationSnapshot = { + regions: [], + excluded: new Set(), + managed: new Set(), + }; private readonly titles = new Map(); private readonly states = new Map(); private pendingReveal: string | undefined; @@ -126,8 +134,14 @@ export class SoloNavigationService extends Service implements SoloNavigation { sources = () => this.contributions.snapshot(); reveal = (channelId: string, explicit = true) => { const target = this.ctx.navigation?.snapshot().entry.target; + const disabled = this.value.regions.some( + (region) => + region.reason === "disabled" && + region.channels.some((channel) => channel.id === channelId), + ); if ( - !this.value.excluded.has(channelId) || + !this.value.managed.has(channelId) || + disabled || (explicit && (target?.kind !== "conversation" || target.channelId !== channelId || @@ -322,7 +336,11 @@ export class SoloNavigationService extends Service implements SoloNavigation { const binding = this.binding; const connection = this.relay.snapshot(); if (!this.session) { - this.value = { regions: [], excluded: new Set() }; + this.value = { + regions: [], + excluded: new Set(), + managed: new Set(), + }; continue; } for (const source of entries) { @@ -381,6 +399,15 @@ export class SoloNavigationService extends Service implements SoloNavigation { runtime.failed = true; } } + const plugins = this.plugins?.snapshot(); + const configuration = plugins?.configuration; + const disabledPluginIds = new Set( + configuration?.status === "ready" + ? configuration.catalog.plugins + .filter((plugin) => !plugin.enabled) + .map((plugin) => plugin.manifest.id) + : [], + ); const placement = projectSoloRegions( this.session.channels.list().channels, connection.viewer ?? this.session.viewer ?? "", @@ -400,10 +427,9 @@ export class SoloNavigationService extends Service implements SoloNavigation { this.titles.get(cacheKey) ?? readView(scope, key, "Plugin chats"); return typeof saved === "string" ? saved : "Plugin chats"; }, + disabledPluginIds, ); - const plugins = this.plugins?.snapshot(); - const configuration = plugins?.configuration; - this.value = { + const next: SoloNavigationSnapshot = { ...placement, regions: placement.regions.map((region) => { if (region.reason !== "off") return region; @@ -411,13 +437,12 @@ export class SoloNavigationService extends Service implements SoloNavigation { return { ...region, reason: "loading" as const }; if (configuration?.status !== "ready") return region; const plugin = configuration.catalog.plugins.find((plugin) => - region.key.startsWith(`${plugin.manifest.id}/`), + region.label.startsWith(`${REGION_PREFIX}${plugin.manifest.id}:`), ); if (!plugin) return { ...region, reason: "not-installed" as const }; if ( - !plugin.enabled || - (plugin.source === "external" && - configuration.externalPluginsPaused) + plugin.source === "external" && + configuration.externalPluginsPaused ) return region; const activation = plugins?.activation[plugin.manifest.id]; @@ -433,6 +458,18 @@ export class SoloNavigationService extends Service implements SoloNavigation { }), ...(binding ? { binding } : {}), }; + this.value = next; + if ( + this.pendingReveal && + next.regions.some( + (region) => + region.reason === "disabled" && + region.channels.some( + (channel) => channel.id === this.pendingReveal, + ), + ) + ) + this.pendingReveal = this.pendingHostReveal = undefined; } while (this.refreshAgain); } finally { this.refreshing = false; diff --git a/src/features/solo/WorkspaceControl.tsx b/src/features/solo/WorkspaceControl.tsx index 2487f3c10b..671dd85dfb 100644 --- a/src/features/solo/WorkspaceControl.tsx +++ b/src/features/solo/WorkspaceControl.tsx @@ -77,7 +77,7 @@ export function WorkspaceControl({ .finally(() => setBusy(false)); }; const name = project ? `Workspace: ${project.name}` : "Add workspace"; - if (channelId && regions.excluded.has(channelId) && !filed) return null; + if (channelId && regions.managed.has(channelId) && !filed) return null; return ( {/* Saving keeps the trigger focusable, so the choice returns focus to it. */} diff --git a/tests/browser/solo-region-state.spec.mjs b/tests/browser/solo-region-state.spec.mjs index e31ef1b8a9..1d6d84bebd 100644 --- a/tests/browser/solo-region-state.spec.mjs +++ b/tests/browser/solo-region-state.spec.mjs @@ -1,7 +1,23 @@ import { test, expect } from "./fixture.mjs"; -import { newChat } from "./navigation.mjs"; +import { setPluginEnabled } from "./plugin-settings.mjs"; +import { + closeSettings, + newChat, + openSettings, + selectSettingsSection, + settleShellToggle, +} from "./navigation.mjs"; -test.use({ pluginFixtures: true, historyCounts: { alpha: 0, beta: 0 } }); +const alpha = "33333333-3333-4333-8333-333333333333"; +const beta = "44444444-4444-4444-8444-444444444444"; + +test.use({ + pluginFixtures: true, + productionBroker: true, + channelIds: [alpha, beta], + channelNames: { [alpha]: "Alpha", [beta]: "Beta" }, + historyCounts: { [alpha]: 0, [beta]: 0 }, +}); // Real storage events between windows and app startup are outside jsdom's boundary. test("region state reaches the component after a headless write and a write in another window", async ({ @@ -44,3 +60,98 @@ test("region state reaches the component after a headless write and a write in a await page.evaluate(() => window.fixtureSoloRegion.status("ready")); await expect(output).toHaveText('{"filter":"needs-you"}'); }); + +test.describe("disabled plugin chat fallback", () => { + test.use({ + soloChannels: [alpha], + ownSoloChannels: [alpha], + historyCounts: { [alpha]: 1, [beta]: 0 }, + }); + + test("keeps an existing chat in Solo while its plugin is off, then reclaims it", async ({ + page, + app, + }) => { + await page.goto(`${app.origin}/?solo-region-fixture`); + await newChat(page); + await page.evaluate((id) => window.fixtureSoloRegion.classify(id), alpha); + + const sidebar = page.getByRole("navigation", { + name: "Solo conversations", + }); + const ordinary = sidebar.locator(`[data-solo-channel-id="${alpha}"]`); + const region = sidebar.locator(`[data-solo-destination-id="${alpha}"]`); + await expect(ordinary).toHaveCount(0); + await expect(region).toHaveCount(1); + await sidebar.getByRole("button", { name: "Alpha", exact: true }).click(); + + const history = page.getByRole("region", { + name: "Channel message history", + exact: true, + }); + await expect(history.locator("[data-message-id]")).toHaveCount(1); + const messageId = await history + .locator("[data-message-id]") + .getAttribute("data-message-id"); + + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); + await setPluginEnabled(page, "Solo region fixture", false); + await closeSettings(page); + + await expect(ordinary).toHaveCount(1); + await expect(region).toHaveCount(0); + await expect( + page.getByText("The plugin for these chats is off.", { exact: true }), + ).toHaveCount(0); + await expect( + sidebar.getByRole("button", { + name: "Open plugin settings", + exact: true, + }), + ).toHaveCount(0); + await expect(history.locator("[data-message-id]")).toHaveAttribute( + "data-message-id", + messageId, + ); + + await page.emulateMedia({ colorScheme: "dark" }); + await page.setViewportSize({ width: 640, height: 900 }); + await page.evaluate(() => + document.documentElement.style.setProperty("--buzz-text-scale", "2"), + ); + await newChat(page); + const showNavigation = page.getByRole("button", { + name: "Show navigation", + exact: true, + }); + if (await showNavigation.isVisible()) await showNavigation.click(); + await expect(ordinary).toBeVisible(); + await ordinary.focus(); + await page.keyboard.press("Enter"); + await expect(history.locator("[data-message-id]")).toHaveAttribute( + "data-message-id", + messageId, + ); + + await openSettings(page); + await selectSettingsSection(page, "Manage plugins"); + await setPluginEnabled(page, "Solo region fixture", true); + await closeSettings(page); + + await settleShellToggle(page); + const reenabledShowNavigation = page.getByRole("button", { + name: "Show navigation", + exact: true, + }); + if (await reenabledShowNavigation.isVisible()) + await reenabledShowNavigation.click(); + await expect(ordinary).toHaveCount(0); + await expect(region).toHaveCount(1); + await expect(region).toBeVisible(); + await expect(history.locator("[data-message-id]")).toHaveAttribute( + "data-message-id", + messageId, + ); + }); +}); From 83762d98442b6164c3bf2bf55dcad7307a18a4a0 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Thu, 12 Nov 2026 23:46:48 -0800 Subject: [PATCH 2/7] fix(solo): preserve disabled chat reveal guards Co-authored-by: Codex Signed-off-by: Kyle Zemek --- .../channel-navigation/SoloRegions.test.tsx | 20 ++++++++++---- .../solo-navigation-model.ts | 5 ++-- .../channel-navigation/solo-navigation.ts | 25 ++++++------------ tests/browser/solo-region-state.spec.mjs | 26 +++++-------------- 4 files changed, 31 insertions(+), 45 deletions(-) diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index b055d20342..283393ee30 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -1358,8 +1358,10 @@ it("shows explicitly disabled region chats once in the ordinary Solo inventory", }, channelId: "owned", } as const; - h.navigate(target); - h.plugins(configuration); + await act(async () => { + await h.navigate(target); + h.plugins(configuration); + }); const ordinary = ( [], title: (label: string, value?: string) => string, - disabledPluginIds: ReadonlySet = new Set(), + isPluginDisabled: (label: string) => boolean = () => false, ): SoloPlacement { const groups = new Map(); for (const source of sources) @@ -63,8 +63,7 @@ export function projectSoloRegions( const current = sources.find( (item) => soloRegionLabelOf(item.source) === label, ); - const pluginId = label.slice(REGION_PREFIX.length).split(":", 1)[0] ?? ""; - const disabled = disabledPluginIds.has(pluginId); + const disabled = isPluginDisabled(label); const reason = disabled ? "disabled" : !current diff --git a/src/features/channel-navigation/solo-navigation.ts b/src/features/channel-navigation/solo-navigation.ts index 836b55a43b..751fa0fe2d 100644 --- a/src/features/channel-navigation/solo-navigation.ts +++ b/src/features/channel-navigation/solo-navigation.ts @@ -134,14 +134,8 @@ export class SoloNavigationService extends Service implements SoloNavigation { sources = () => this.contributions.snapshot(); reveal = (channelId: string, explicit = true) => { const target = this.ctx.navigation?.snapshot().entry.target; - const disabled = this.value.regions.some( - (region) => - region.reason === "disabled" && - region.channels.some((channel) => channel.id === channelId), - ); if ( - !this.value.managed.has(channelId) || - disabled || + !this.value.excluded.has(channelId) || (explicit && (target?.kind !== "conversation" || target.channelId !== channelId || @@ -401,13 +395,12 @@ export class SoloNavigationService extends Service implements SoloNavigation { } const plugins = this.plugins?.snapshot(); const configuration = plugins?.configuration; - const disabledPluginIds = new Set( + const pluginForRegionLabel = (label: string) => configuration?.status === "ready" - ? configuration.catalog.plugins - .filter((plugin) => !plugin.enabled) - .map((plugin) => plugin.manifest.id) - : [], - ); + ? configuration.catalog.plugins.find((plugin) => + label.startsWith(`${REGION_PREFIX}${plugin.manifest.id}:`), + ) + : undefined; const placement = projectSoloRegions( this.session.channels.list().channels, connection.viewer ?? this.session.viewer ?? "", @@ -427,7 +420,7 @@ export class SoloNavigationService extends Service implements SoloNavigation { this.titles.get(cacheKey) ?? readView(scope, key, "Plugin chats"); return typeof saved === "string" ? saved : "Plugin chats"; }, - disabledPluginIds, + (label) => pluginForRegionLabel(label)?.enabled === false, ); const next: SoloNavigationSnapshot = { ...placement, @@ -436,9 +429,7 @@ export class SoloNavigationService extends Service implements SoloNavigation { if (configuration?.status === "loading") return { ...region, reason: "loading" as const }; if (configuration?.status !== "ready") return region; - const plugin = configuration.catalog.plugins.find((plugin) => - region.label.startsWith(`${REGION_PREFIX}${plugin.manifest.id}:`), - ); + const plugin = pluginForRegionLabel(region.label); if (!plugin) return { ...region, reason: "not-installed" as const }; if ( plugin.source === "external" && diff --git a/tests/browser/solo-region-state.spec.mjs b/tests/browser/solo-region-state.spec.mjs index 1d6d84bebd..384bb5adb8 100644 --- a/tests/browser/solo-region-state.spec.mjs +++ b/tests/browser/solo-region-state.spec.mjs @@ -13,10 +13,7 @@ const beta = "44444444-4444-4444-8444-444444444444"; test.use({ pluginFixtures: true, - productionBroker: true, - channelIds: [alpha, beta], - channelNames: { [alpha]: "Alpha", [beta]: "Beta" }, - historyCounts: { [alpha]: 0, [beta]: 0 }, + historyCounts: { alpha: 0, beta: 0 }, }); // Real storage events between windows and app startup are outside jsdom's boundary. @@ -63,6 +60,9 @@ test("region state reaches the component after a headless write and a write in a test.describe("disabled plugin chat fallback", () => { test.use({ + productionBroker: true, + channelIds: [alpha, beta], + channelNames: { [alpha]: "Alpha", [beta]: "Beta" }, soloChannels: [alpha], ownSoloChannels: [alpha], historyCounts: { [alpha]: 1, [beta]: 0 }, @@ -115,17 +115,8 @@ test.describe("disabled plugin chat fallback", () => { messageId, ); - await page.emulateMedia({ colorScheme: "dark" }); - await page.setViewportSize({ width: 640, height: 900 }); - await page.evaluate(() => - document.documentElement.style.setProperty("--buzz-text-scale", "2"), - ); await newChat(page); - const showNavigation = page.getByRole("button", { - name: "Show navigation", - exact: true, - }); - if (await showNavigation.isVisible()) await showNavigation.click(); + await expect(sidebar).toBeVisible(); await expect(ordinary).toBeVisible(); await ordinary.focus(); await page.keyboard.press("Enter"); @@ -140,12 +131,7 @@ test.describe("disabled plugin chat fallback", () => { await closeSettings(page); await settleShellToggle(page); - const reenabledShowNavigation = page.getByRole("button", { - name: "Show navigation", - exact: true, - }); - if (await reenabledShowNavigation.isVisible()) - await reenabledShowNavigation.click(); + await expect(sidebar).toBeVisible(); await expect(ordinary).toHaveCount(0); await expect(region).toHaveCount(1); await expect(region).toBeVisible(); From 755ec959bba96a4a804be27c423198db7cda3682 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 13 Nov 2026 03:29:25 -0800 Subject: [PATCH 3/7] fix(solo): avoid disabled reveal replay Co-authored-by: Codex Signed-off-by: Kyle Zemek --- .../solo-navigation-testing.ts | 7 +- .../solo-navigation.test.ts | 70 ++++++++++++++++++- .../channel-navigation/solo-navigation.ts | 17 +++-- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/features/channel-navigation/solo-navigation-testing.ts b/src/features/channel-navigation/solo-navigation-testing.ts index 34c7585d1e..225b10a5eb 100644 --- a/src/features/channel-navigation/solo-navigation-testing.ts +++ b/src/features/channel-navigation/solo-navigation-testing.ts @@ -131,7 +131,10 @@ export function createSoloRegionFixture(viewer = "a".repeat(64)) { async clearCache() {}, }; const service = new SoloNavigationService(root, relay, plugins); - const register = async (changes: Partial = {}) => { + const register = async ( + changes: Partial = {}, + pluginId = "example", + ) => { let snapshot: SoloRegionSnapshot; let binding!: SoloRegionBinding; let signal!: AbortSignal; @@ -157,7 +160,7 @@ export function createSoloRegionFixture(viewer = "a".repeat(64)) { ...changes, }; const fiber = root - .extend({ pluginOwner: { id: "example", revision: "one" } }) + .extend({ pluginOwner: { id: pluginId, revision: "one" } }) .plugin((ctx) => { assert(ctx.soloNavigation); handle = ctx.soloNavigation.registerSource(source); diff --git a/src/features/channel-navigation/solo-navigation.test.ts b/src/features/channel-navigation/solo-navigation.test.ts index a9ab1def65..1ed13e3eb9 100644 --- a/src/features/channel-navigation/solo-navigation.test.ts +++ b/src/features/channel-navigation/solo-navigation.test.ts @@ -537,7 +537,15 @@ it("keeps disabled chats ordinary through loading, re-enable, and reconnect", as it("does not reveal disabled chats or replay a reveal queued before disabling", async () => { const h = fixture(); - await h.register(); + const source = await h.register(); + const sourceEntry = h.service.sources()[0]; + if (!sourceEntry) throw new Error("Expected the registered Solo source"); + const revealRequests = h.service.presentation( + sourceEntry, + source.binding(), + ).revealRequests; + const sourceReveal = vi.fn(); + const stopSourceReveal = revealRequests.subscribe(sourceReveal); await h.navigate({ version: 1, kind: "conversation", @@ -549,6 +557,7 @@ it("does not reveal disabled chats or replay a reveal queued before disabling", }); const listener = vi.fn(); h.service.reveal("owned"); + expect(sourceReveal).toHaveBeenCalledWith("owned"); h.plugins(pluginConfiguration(false), { example: { revision: "one", status: "active", error: null }, }); @@ -559,6 +568,65 @@ it("does not reveal disabled chats or replay a reveal queued before disabling", const stop = h.service.subscribeReveal(listener, true); expect(listener).not.toHaveBeenCalled(); stop(); + stopSourceReveal(); + + h.service.reveal("owned"); + const replayed = vi.fn(() => true); + const stopReplay = h.service.subscribeReveal(replayed, true); + expect(replayed).toHaveBeenCalledWith("owned", true); + stopReplay(); +}); + +it("skips a disabled source when opening a newly created workspace", async () => { + const h = fixture(); + const disabledOpen = vi.fn(async () => {}); + const enabledOpen = vi.fn(async () => {}); + await h.register({ + order: -1, + composeWorkspaces: true, + openWorkspace: disabledOpen, + }); + await h.register( + { + composeWorkspaces: true, + openWorkspace: enabledOpen, + }, + "another", + ); + const disabledConfiguration = pluginConfiguration(false); + if (disabledConfiguration.status !== "ready") + throw new Error("Expected plugin configuration to be ready"); + const example = disabledConfiguration.catalog.plugins[0]; + if (!example) throw new Error("Expected the example plugin"); + h.plugins( + { + ...disabledConfiguration, + catalog: { + ...disabledConfiguration.catalog, + plugins: [ + example, + { + ...example, + manifest: { ...example.manifest, id: "another", name: "Another" }, + enabled: true, + }, + ], + }, + }, + { + another: { revision: "one", status: "active", error: null }, + }, + ); + + expect(h.service.snapshot().regions[0]?.reason).toBe("disabled"); + await expect( + h.service.openCreatedWorkspace("created-workspace"), + ).resolves.toBe(true); + expect(disabledOpen).not.toHaveBeenCalled(); + expect(enabledOpen).toHaveBeenCalledWith( + expect.objectContaining({ viewer: h.session.viewer }), + "created-workspace", + ); }); it.each(["starting", "failed", "paused", "stale"] as const)( diff --git a/src/features/channel-navigation/solo-navigation.ts b/src/features/channel-navigation/solo-navigation.ts index 751fa0fe2d..97f271c822 100644 --- a/src/features/channel-navigation/solo-navigation.ts +++ b/src/features/channel-navigation/solo-navigation.ts @@ -450,17 +450,16 @@ export class SoloNavigationService extends Service implements SoloNavigation { ...(binding ? { binding } : {}), }; this.value = next; - if ( - this.pendingReveal && + const isDisabledChat = (id: string | undefined) => + id !== undefined && next.regions.some( (region) => region.reason === "disabled" && - region.channels.some( - (channel) => channel.id === this.pendingReveal, - ), - ) - ) - this.pendingReveal = this.pendingHostReveal = undefined; + region.channels.some((channel) => channel.id === id), + ); + if (isDisabledChat(this.pendingReveal)) this.pendingReveal = undefined; + if (isDisabledChat(this.pendingHostReveal?.id)) + this.pendingHostReveal = undefined; } while (this.refreshAgain); } finally { this.refreshing = false; @@ -477,7 +476,7 @@ export class SoloNavigationService extends Service implements SoloNavigation { } async openCreatedWorkspace(workspaceId: string): Promise { const region = this.value.regions.find( - (item) => item.source && !item.hidden, + (item) => item.source && !item.hidden && item.reason !== "disabled", ); const source = region?.source; if (!source?.composeWorkspaces || !source.openWorkspace) return false; From cd6c04f8405cbf63820aa3d2369f8b67bd175dc2 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 13 Nov 2026 04:41:23 -0800 Subject: [PATCH 4/7] fix(solo): hide disabled region controls Co-authored-by: Codex Signed-off-by: Kyle Zemek --- .../channel-navigation/SoloInventory.tsx | 2 +- .../channel-navigation/SoloRegions.test.tsx | 17 ++++++++++++++++- .../channel-navigation/solo-navigation.test.ts | 2 ++ src/features/solo/chat-workspace.ts | 5 +++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/features/channel-navigation/SoloInventory.tsx b/src/features/channel-navigation/SoloInventory.tsx index 3f38aa710a..31ffe3b9dd 100644 --- a/src/features/channel-navigation/SoloInventory.tsx +++ b/src/features/channel-navigation/SoloInventory.tsx @@ -254,7 +254,7 @@ export function SoloInventory({ JSON.stringify(conversations.map((item) => item.id)), ); const composing = !!regions.regions.find( - (region) => region.source && !region.hidden, + (region) => region.source && !region.hidden && region.reason !== "disabled", )?.source?.composeWorkspaces; const empty = list.status === "ready" && diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 283393ee30..802f8e02ee 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -1312,6 +1312,10 @@ it("keeps recovery chats reachable while distinguishing installed, unknown and n it("shows explicitly disabled region chats once in the ordinary Solo inventory", async () => { const h = fixture(); + await h.register({ + composeWorkspaces: true, + workspace: () => ({ id: "personal", file: async () => {} }), + }); const saved = { sections: [{ id: "personal", name: "Personal", order: 0 }], assignments: { owned: "personal" }, @@ -1392,6 +1396,9 @@ it("shows explicitly disabled region chats once in the ordinary Solo inventory", ); expect(screen.getByRole("button", { name: "Owned" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "New chat or workspace" }), + ).toBeInTheDocument(); for (const id of ["owned", "shared"]) { expect( document.querySelectorAll(`[data-solo-channel-id="${id}"]`), @@ -1427,10 +1434,18 @@ it("shows explicitly disabled region chats once in the ordinary Solo inventory", expect( screen.queryByRole("button", { name: "Workspace: Personal" }), ).not.toBeInTheDocument(); + const inventoryActions = screen.getByRole("button", { + name: "New chat or workspace", + }); + const user = userEvent.setup(); + await user.click(inventoryActions); + expect( + await screen.findByRole("menuitem", { name: "New chat" }), + ).toBeInTheDocument(); + await user.keyboard("{Escape}"); expect( screen.getByRole("button", { name: "Chat actions" }), ).toBeInTheDocument(); - const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: "Chat actions" })); expect( await screen.findByRole("menuitem", { name: "Chat settings" }), diff --git a/src/features/channel-navigation/solo-navigation.test.ts b/src/features/channel-navigation/solo-navigation.test.ts index 1ed13e3eb9..20f28957e1 100644 --- a/src/features/channel-navigation/solo-navigation.test.ts +++ b/src/features/channel-navigation/solo-navigation.test.ts @@ -562,9 +562,11 @@ it("does not reveal disabled chats or replay a reveal queued before disabling", example: { revision: "one", status: "active", error: null }, }); h.service.reveal("owned"); + expect(sourceReveal).toHaveBeenCalledTimes(1); h.plugins(pluginConfiguration(true), { example: { revision: "one", status: "active", error: null }, }); + expect(sourceReveal).toHaveBeenCalledTimes(1); const stop = h.service.subscribeReveal(listener, true); expect(listener).not.toHaveBeenCalled(); stop(); diff --git a/src/features/solo/chat-workspace.ts b/src/features/solo/chat-workspace.ts index af20c42b84..49a630a0ac 100644 --- a/src/features/solo/chat-workspace.ts +++ b/src/features/solo/chat-workspace.ts @@ -65,11 +65,12 @@ export function useRegionWorkspace( return last; }; }, [reader, channelId]); - const { binding, source } = useSyncExternalStore( + const { binding, source, reason } = useSyncExternalStore( reader?.subscribe ?? noSubscription, owner, ); - if (!channelId || !binding || !source?.workspace) return undefined; + if (!channelId || !binding || reason === "disabled" || !source?.workspace) + return undefined; try { return source.workspace(binding, channelId); } catch { From c680990a034d55014aeb34ad44940add0e491e5f Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 13 Nov 2026 07:07:28 -0800 Subject: [PATCH 5/7] fix(solo): explain managed chat move refusals Surface the existing filing refusal for ordinary-looking managed chats. Cover mixed region composition and pointer-driven refusal without changing region ownership. Co-authored-by: Codex Signed-off-by: Kyle Zemek --- src/bundled/channels/SoloWorkspaces.tsx | 7 +- .../channel-navigation/SoloRegions.test.tsx | 343 +++++++++++------- tests/browser/solo-region-state.spec.mjs | 30 +- 3 files changed, 237 insertions(+), 143 deletions(-) diff --git a/src/bundled/channels/SoloWorkspaces.tsx b/src/bundled/channels/SoloWorkspaces.tsx index 63bdb75c92..db09f55a2b 100644 --- a/src/bundled/channels/SoloWorkspaces.tsx +++ b/src/bundled/channels/SoloWorkspaces.tsx @@ -277,7 +277,12 @@ export function SoloWorkspaces({ project.rows.some((item) => item.id === renaming), )?.id; const move = (id: string, projectId?: string) => { - if (prepareSoloMove(session, id)) return; + const refusal = prepareSoloMove(session, id); + if (refusal) { + setProjectError(refusal); + return; + } + setProjectError(""); const saving = preferences.assign(id, projectId); toggle(projectId ?? "", true); hold([id]); diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 802f8e02ee..17addcd0e7 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -1310,153 +1310,214 @@ it("keeps recovery chats reachable while distinguishing installed, unknown and n expect(screen.queryByText(/buzz\.region:/)).not.toBeInTheDocument(); }); -it("shows explicitly disabled region chats once in the ordinary Solo inventory", async () => { - const h = fixture(); - await h.register({ - composeWorkspaces: true, - workspace: () => ({ id: "personal", file: async () => {} }), - }); - const saved = { - sections: [{ id: "personal", name: "Personal", order: 0 }], - assignments: { owned: "personal" }, - starred: [], - muted: [], - }; - const preferences = { status: "ready" as const, data: saved }; - Object.assign(h.session, { - soloPreferences: { - ...h.session.soloPreferences, - snapshot: () => preferences, - subscribe: () => () => {}, - }, - }); - const configuration: ConfigurationState = { - status: "ready", - externalPluginsPaused: false, - catalog: { - profile: "fixture", - location: "Fixture", - plugins: [ +it.each([false, true])( + "shows explicitly disabled region chats once in the ordinary Solo inventory (composing source: %s)", + async (composing) => { + const h = fixture(); + await h.register({ + composeWorkspaces: true, + workspace: () => ({ id: "personal", file: async () => {} }), + }); + if (composing) + await h.register( { - manifest: { id: "example", name: "Example", apiVersion: 1 }, - source: "external", - enabled: false, - revision: "one", - previous: null, - reloadable: false, - error: null, + title: "Other work", + composeWorkspaces: true, + component: ({ ui }) => { + const Chats = ui.Chats; + const Workspace = ui.Workspace; + assert(Chats); + assert(Workspace); + return ( + <> + + + + ); + }, }, - ], - }, - }; - const channel = h.session.channels - .list() - .channels.find((item) => item.id === "owned"); - assert(channel); - const target = { - version: 1, - kind: "conversation", - scope: { - communityOrigin: "https://fixture.invalid", - viewer: h.session.viewer ?? "", - }, - channelId: "owned", - } as const; - await act(async () => { - await h.navigate(target); - h.plugins(configuration); - }); - const ordinary = ( - - ); - mount( - h, - target, - (children) => ( - - {children} - preferences, + subscribe: () => () => {}, + }, + }); + const configuration: ConfigurationState = { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "Fixture", + plugins: [ + { + manifest: { id: "example", name: "Example", apiVersion: 1 }, + source: "external", + enabled: false, + revision: "one", + previous: null, + reloadable: false, + error: null, + }, + ...(composing + ? [ + { + manifest: { + id: "other", + name: "Other", + apiVersion: 1 as const, + }, + source: "external" as const, + enabled: true, + revision: "one", + previous: null, + reloadable: false, + error: null, + }, + ] + : []), + ], + }, + }; + const channel = h.session.channels + .list() + .channels.find((item) => item.id === "owned"); + assert(channel); + const target = { + version: 1, + kind: "conversation", + scope: { + communityOrigin: "https://fixture.invalid", + viewer: h.session.viewer ?? "", + }, + channelId: "owned", + } as const; + await act(async () => { + await h.navigate(target); + h.plugins(configuration); + }); + function Inventory() { + const sidebar = useSidebarView(h.scope, true); + return ( + {}} - onLifecycle={() => {}} - onMove={() => {}} - > - - - - ), - ordinary, - ); + navigator={h.navigator} + target={target} + renderContents={(ordinary, header, pendingClick, composeOrdinary) => ( + + {header} + {ordinary} + + } + /> + )} + /> + ); + } + render( + + + + {}} + onLifecycle={() => {}} + onMove={() => {}} + > + + + + , + ); - expect(screen.getByRole("button", { name: "Owned" })).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: "New chat or workspace" }), - ).toBeInTheDocument(); - for (const id of ["owned", "shared"]) { + expect(screen.getByRole("button", { name: "Owned" })).toHaveAttribute( + "aria-current", + "page", + ); + if (composing) + expect(screen.getByLabelText("Other work")).toBeInTheDocument(); + for (const id of ["owned", "shared"]) { + expect( + document.querySelectorAll(`[data-solo-channel-id="${id}"]`), + ).toHaveLength(1); + } + expect(screen.getByRole("button", { name: "Shared" })).toBeInTheDocument(); expect( - document.querySelectorAll(`[data-solo-channel-id="${id}"]`), - ).toHaveLength(1); - } - expect(screen.getByRole("button", { name: "Shared" })).toBeInTheDocument(); - expect( - document - .querySelector('[data-solo-project="personal"]') - ?.querySelector('[data-solo-channel-id="owned"]'), - ).toBeNull(); - expect( - document - .querySelector('[data-solo-project=""]') - ?.querySelector('[data-solo-channel-id="owned"]'), - ).not.toBeNull(); - expect( - document - .querySelector('[data-solo-project=""]') - ?.querySelector('[data-solo-channel-id="shared"]'), - ).not.toBeNull(); - expect(saved.assignments).toEqual({ owned: "personal" }); - expect( - screen.queryByText("The plugin for these chats is off."), - ).not.toBeInTheDocument(); - expect(screen.queryByText("Work could not load")).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Open plugin settings" }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "In Plugin chats" }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: "Workspace: Personal" }), - ).not.toBeInTheDocument(); - const inventoryActions = screen.getByRole("button", { - name: "New chat or workspace", - }); - const user = userEvent.setup(); - await user.click(inventoryActions); - expect( - await screen.findByRole("menuitem", { name: "New chat" }), - ).toBeInTheDocument(); - await user.keyboard("{Escape}"); - expect( - screen.getByRole("button", { name: "Chat actions" }), - ).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Chat actions" })); - expect( - await screen.findByRole("menuitem", { name: "Chat settings" }), - ).toBeInTheDocument(); - expect( - screen.queryByRole("menuitem", { name: "Move to" }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("menuitem", { name: /convert/i }), - ).not.toBeInTheDocument(); -}); + document + .querySelector('[data-solo-project="personal"]') + ?.querySelector('[data-solo-channel-id="owned"]'), + ).toBeNull(); + expect( + document + .querySelector('[data-solo-project=""]') + ?.querySelector('[data-solo-channel-id="owned"]'), + ).not.toBeNull(); + expect( + document + .querySelector('[data-solo-project=""]') + ?.querySelector('[data-solo-channel-id="shared"]'), + ).not.toBeNull(); + expect(saved.assignments).toEqual({ owned: "personal" }); + expect( + screen.queryByText("The plugin for these chats is off."), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Work could not load")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Open plugin settings" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "In Plugin chats" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Workspace: Personal" }), + ).not.toBeInTheDocument(); + const user = userEvent.setup(); + if (!composing) { + await user.click( + screen.getByRole("button", { name: "New chat or workspace" }), + ); + expect( + await screen.findByRole("menuitem", { name: "New chat" }), + ).toBeInTheDocument(); + await user.keyboard("{Escape}"); + } + expect( + screen.getByRole("button", { name: "Chat actions" }), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Chat actions" })); + expect( + await screen.findByRole("menuitem", { name: "Chat settings" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: "Move to" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /convert/i }), + ).not.toBeInTheDocument(); + }, +); it("uses shared populated menus for header choices and refuses retired menu callbacks", async () => { const h = fixture(); diff --git a/tests/browser/solo-region-state.spec.mjs b/tests/browser/solo-region-state.spec.mjs index 384bb5adb8..947cd511a9 100644 --- a/tests/browser/solo-region-state.spec.mjs +++ b/tests/browser/solo-region-state.spec.mjs @@ -74,7 +74,10 @@ test.describe("disabled plugin chat fallback", () => { }) => { await page.goto(`${app.origin}/?solo-region-fixture`); await newChat(page); - await page.evaluate((id) => window.fixtureSoloRegion.classify(id), alpha); + await page.evaluate(async (id) => { + await window.fixtureSoloRegion.createWorkspace(); + await window.fixtureSoloRegion.classify(id); + }, alpha); const sidebar = page.getByRole("navigation", { name: "Solo conversations", @@ -115,6 +118,31 @@ test.describe("disabled plugin chat fallback", () => { messageId, ); + // Pointer hit testing reaches the filing guard even for an ordinary-looking row. + const workspace = sidebar.getByRole("button", { + name: "Work", + exact: true, + }); + await expect(workspace).toBeVisible(); + const from = await ordinary.boundingBox(); + const to = await workspace.boundingBox(); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { + steps: 8, + }); + await expect(sidebar.locator("[data-drop-target]")).toHaveCount(1); + await page.mouse.up(); + await expect(sidebar.getByRole("alert")).toContainText( + "This chat is managed by", + ); + await expect(sidebar.locator("[data-channel-dragging]")).toHaveCount(0); + await expect( + sidebar + .locator('[data-solo-project="fixture-workspace"]') + .locator(`[data-solo-channel-id="${alpha}"]`), + ).toHaveCount(0); + await newChat(page); await expect(sidebar).toBeVisible(); await expect(ordinary).toBeVisible(); From c79c56e53f0164ce1b36010c36cf38d2f7ab3e19 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 13 Nov 2026 07:32:56 -0800 Subject: [PATCH 6/7] test(solo): cover hidden disabled region recovery Keep explicit-disabled fallback compatible with hidden regions and restore hidden ownership on re-enable. Keep the failed-release fixture on a supported retained version. Co-authored-by: Codex Signed-off-by: Kyle Zemek --- docs/solo-regions.md | 7 +- src/bundled/claude-code/setup.test.ts | 6 +- .../channel-navigation/SoloRegions.test.tsx | 19 ++++-- .../solo-navigation.test.ts | 67 +++++++++++-------- 4 files changed, 61 insertions(+), 38 deletions(-) diff --git a/docs/solo-regions.md b/docs/solo-regions.md index 222f79a714..12fa29ba8a 100644 --- a/docs/solo-regions.md +++ b/docs/solo-regions.md @@ -28,9 +28,10 @@ host draws no heading, rows, header actions, or "In" chip for it, and it neither names the Solo tab nor composes its workspaces. The region stays in the shared projection, so its labeled chats stay out of the Solo list, its filing guard and workspace still apply, and its drafts still start; the chats remain reachable by -links, notifications, and search, and the source presents them in its own UI. Any -other state (off, loading, unavailable, failed) shows the recovery region as -usual, so the chats never vanish when the source cannot present them. +links, notifications, and search, and the source presents them in its own UI. If the +plugin is explicitly disabled, its chats appear in ordinary Solo instead. Other +non-ready states (off, loading, unavailable, failed) show the recovery region, +so the chats remain reachable when the source cannot present them. The source owns its content, presentation, and disclosure controls. It receives the stable `ui.Conversation` component. Conversation renders only live channels diff --git a/src/bundled/claude-code/setup.test.ts b/src/bundled/claude-code/setup.test.ts index 89a265434a..d1e48be856 100644 --- a/src/bundled/claude-code/setup.test.ts +++ b/src/bundled/claude-code/setup.test.ts @@ -708,7 +708,7 @@ it("keeps the copy in use when a new release fails its test", async () => { it("says why a new release quit during its test", async () => { const { spawn, request, state } = releases("2.1.300", { - current: "2.1.200", + current: "2.1.280", quits: { version: "2.1.300", stderr: "Starting Claude\nError: bypassPermissions is disabled\n", @@ -719,8 +719,8 @@ it("says why a new release quit during its test", async () => { expect(state.used).toEqual([]); expect(setup.snapshot()).toMatchObject({ notice: - "Claude 2.1.300 failed its test: Error: bypassPermissions is disabled. Buzz keeps using 2.1.200.", - status: { state: "ready", version: "2.1.200" }, + "Claude 2.1.300 failed its test: Error: bypassPermissions is disabled. Buzz keeps using 2.1.280.", + status: { state: "ready", version: "2.1.280" }, }); }); diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 17addcd0e7..4619d8d5d3 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -1310,14 +1310,25 @@ it("keeps recovery chats reachable while distinguishing installed, unknown and n expect(screen.queryByText(/buzz\.region:/)).not.toBeInTheDocument(); }); -it.each([false, true])( - "shows explicitly disabled region chats once in the ordinary Solo inventory (composing source: %s)", - async (composing) => { +it.each([ + { composing: false, hidden: false }, + { composing: true, hidden: false }, + { composing: false, hidden: true }, + { composing: true, hidden: true }, +])( + "shows explicitly disabled region chats once in ordinary Solo (composing: $composing, hidden: $hidden)", + async ({ composing, hidden }) => { const h = fixture(); - await h.register({ + const source = await h.register({ composeWorkspaces: true, workspace: () => ({ id: "personal", file: async () => {} }), }); + source.update({ + status: "ready", + binding: source.binding(), + revision: 2, + hidden, + }); if (composing) await h.register( { diff --git a/src/features/channel-navigation/solo-navigation.test.ts b/src/features/channel-navigation/solo-navigation.test.ts index 20f28957e1..ff58d866de 100644 --- a/src/features/channel-navigation/solo-navigation.test.ts +++ b/src/features/channel-navigation/solo-navigation.test.ts @@ -450,36 +450,47 @@ it.each([ }); }); -it("shows explicitly disabled region chats in ordinary Solo and reclaims them when re-enabled", async () => { - const h = fixture(); - await h.register(); - const active = { - example: { revision: "one", status: "active" as const, error: null }, - }; - const before = h.session.channels - .list() - .channels.find((channel) => channel.id === "owned"); - expect([...h.service.snapshot().excluded]).toEqual(["owned", "shared"]); - - h.plugins(pluginConfiguration(false), active); - const disabled = h.service.snapshot(); - expect([...disabled.excluded]).toEqual([]); - expect([...disabled.managed]).toEqual(["owned", "shared"]); - expect(prepareSoloMove(h.session, "owned")).toBe( - "This chat is managed by Work.", - ); - expect(prepareSoloMove(h.session, "chat")).toBeUndefined(); - expect( - h.session.channels +it.each([false, true])( + "shows disabled region chats in ordinary Solo and reclaims them when re-enabled (hidden: %s)", + async (hidden) => { + const h = fixture(); + const source = await h.register(); + source.update({ + status: "ready", + binding: source.binding(), + revision: 2, + hidden, + }); + const active = { + example: { revision: "one", status: "active" as const, error: null }, + }; + const before = h.session.channels .list() - .channels.find((channel) => channel.id === "owned"), - ).toEqual(before); + .channels.find((channel) => channel.id === "owned"); + expect([...h.service.snapshot().excluded]).toEqual(["owned", "shared"]); - h.plugins(pluginConfiguration(true), active); - const enabled = h.service.snapshot(); - expect([...enabled.excluded]).toEqual(["owned", "shared"]); - expect([...enabled.managed]).toEqual(["owned", "shared"]); -}); + h.plugins(pluginConfiguration(false), active); + const disabled = h.service.snapshot(); + expect(disabled.regions[0]?.hidden).toBeUndefined(); + expect([...disabled.excluded]).toEqual([]); + expect([...disabled.managed]).toEqual(["owned", "shared"]); + expect(prepareSoloMove(h.session, "owned")).toBe( + "This chat is managed by Work.", + ); + expect(prepareSoloMove(h.session, "chat")).toBeUndefined(); + expect( + h.session.channels + .list() + .channels.find((channel) => channel.id === "owned"), + ).toEqual(before); + + h.plugins(pluginConfiguration(true), active); + const enabled = h.service.snapshot(); + expect(enabled.regions[0]?.hidden).toBe(hidden || undefined); + expect([...enabled.excluded]).toEqual(["owned", "shared"]); + expect([...enabled.managed]).toEqual(["owned", "shared"]); + }, +); it("matches disabled plugins by exact id without misclassifying an uninstalled plugin", () => { const h = fixture(); From a1f6ae16daa7b2cf451ef1516043155eb4789320 Mon Sep 17 00:00:00 2001 From: Kyle Zemek Date: Fri, 13 Nov 2026 08:28:49 -0800 Subject: [PATCH 7/7] test(solo): await drag cleanup and assert fixture chip Co-authored-by: Codex Signed-off-by: Kyle Zemek --- src/features/channel-navigation/SoloRegions.test.tsx | 2 +- tests/browser/solo-region-state.spec.mjs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index 4619d8d5d3..d917d42068 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -1499,7 +1499,7 @@ it.each([ screen.queryByRole("button", { name: "Open plugin settings" }), ).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "In Plugin chats" }), + screen.queryByRole("button", { name: "In Work" }), ).not.toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Workspace: Personal" }), diff --git a/tests/browser/solo-region-state.spec.mjs b/tests/browser/solo-region-state.spec.mjs index 947cd511a9..1411703e66 100644 --- a/tests/browser/solo-region-state.spec.mjs +++ b/tests/browser/solo-region-state.spec.mjs @@ -1,4 +1,5 @@ import { test, expect } from "./fixture.mjs"; +import { pauseClock } from "./clock.mjs"; import { setPluginEnabled } from "./plugin-settings.mjs"; import { closeSettings, @@ -72,6 +73,7 @@ test.describe("disabled plugin chat fallback", () => { page, app, }) => { + await page.clock.install(); await page.goto(`${app.origin}/?solo-region-fixture`); await newChat(page); await page.evaluate(async (id) => { @@ -132,7 +134,14 @@ test.describe("disabled plugin chat fallback", () => { steps: 8, }); await expect(sidebar.locator("[data-drop-target]")).toHaveCount(1); - await page.mouse.up(); + await pauseClock(page); + try { + await page.mouse.up(); + // dnd-kit retires its document capture click blocker after pointer-up. + await page.clock.runFor(50); + } finally { + await page.clock.resume(); + } await expect(sidebar.getByRole("alert")).toContainText( "This chat is managed by", );