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..12fa29ba8a 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 @@ -23,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/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..db09f55a2b 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 @@ -272,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/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/SoloInventory.tsx b/src/features/channel-navigation/SoloInventory.tsx index 4a993ab092..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" && @@ -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,226 @@ it("keeps recovery chats reachable while distinguishing installed, unknown and n expect(screen.queryByText(/buzz\.region:/)).not.toBeInTheDocument(); }); +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(); + 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( + { + title: "Other work", + composeWorkspaces: true, + component: ({ ui }) => { + const Chats = ui.Chats; + const Workspace = ui.Workspace; + assert(Chats); + assert(Workspace); + return ( + <> + + + + ); + }, + }, + "other", + ); + 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, + }, + ...(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 ( + ( + + {header} + {ordinary} + + } + /> + )} + /> + ); + } + render( + + + + {}} + onLifecycle={() => {}} + onMove={() => {}} + > + + + + , + ); + + 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 + .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 Work" }), + ).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(); 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..52d783248a 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, + isPluginDisabled: (label: string) => boolean = () => false, ): SoloPlacement { const groups = new Map(); for (const source of sources) @@ -49,19 +57,26 @@ 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 disabled = isPluginDisabled(label); + 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 +98,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..225b10a5eb 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, @@ -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 652a7de119..ff58d866de 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,199 @@ it.each([ }); }); -it.each(["starting", "failed", "disabled", "paused", "stale"] as const)( +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"); + expect([...h.service.snapshot().excluded]).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(); + 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(); + 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", + scope: { + communityOrigin: "https://fixture.invalid", + viewer: h.session.viewer ?? "", + }, + channelId: "owned", + }); + const listener = vi.fn(); + h.service.reveal("owned"); + expect(sourceReveal).toHaveBeenCalledWith("owned"); + h.plugins(pluginConfiguration(false), { + 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(); + 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)( "uses the installed plugin's current activation for recovery (%s)", (state) => { const h = fixture(); @@ -445,7 +657,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..97f271c822 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; @@ -322,7 +330,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 +393,14 @@ export class SoloNavigationService extends Service implements SoloNavigation { runtime.failed = true; } } + const plugins = this.plugins?.snapshot(); + const configuration = plugins?.configuration; + const pluginForRegionLabel = (label: string) => + configuration?.status === "ready" + ? 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 ?? "", @@ -400,24 +420,20 @@ export class SoloNavigationService extends Service implements SoloNavigation { this.titles.get(cacheKey) ?? readView(scope, key, "Plugin chats"); return typeof saved === "string" ? saved : "Plugin chats"; }, + (label) => pluginForRegionLabel(label)?.enabled === false, ); - 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; if (configuration?.status === "loading") 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}/`), - ); + const plugin = pluginForRegionLabel(region.label); 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 +449,17 @@ export class SoloNavigationService extends Service implements SoloNavigation { }), ...(binding ? { binding } : {}), }; + this.value = next; + const isDisabledChat = (id: string | undefined) => + id !== undefined && + next.regions.some( + (region) => + region.reason === "disabled" && + 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; @@ -449,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; 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/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 { diff --git a/tests/browser/solo-region-state.spec.mjs b/tests/browser/solo-region-state.spec.mjs index e31ef1b8a9..1411703e66 100644 --- a/tests/browser/solo-region-state.spec.mjs +++ b/tests/browser/solo-region-state.spec.mjs @@ -1,7 +1,21 @@ import { test, expect } from "./fixture.mjs"; -import { newChat } from "./navigation.mjs"; +import { pauseClock } from "./clock.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, + 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 +58,123 @@ 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({ + productionBroker: true, + channelIds: [alpha, beta], + channelNames: { [alpha]: "Alpha", [beta]: "Beta" }, + 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.clock.install(); + await page.goto(`${app.origin}/?solo-region-fixture`); + await newChat(page); + await page.evaluate(async (id) => { + await window.fixtureSoloRegion.createWorkspace(); + await 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, + ); + + // 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 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", + ); + 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(); + 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); + await expect(sidebar).toBeVisible(); + 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, + ); + }); +});