0287dfb0c2
Clicking a bot in the roster always reopened its pinned canonical Bot Chat. Start a new conversation with bot A, click bot B, click back to A — the new conversation was gone, replaced by the pinned transcript. A bot row is a workspace entry point, so it has to land on the live conversation. Two independent causes, both fixed here: 1. The pin overrode newer work. `openBotCanonicalChat` opened the pin unconditionally. It now prefers the bot's freshest VISIBLE session — but only AFTER `profiles.list` has verified through `preferred_session` that the pin is alive and is a real canonical Bot Chat. That ordering matters: with a dead or unverified pin, adopting the profile's latest row would claim an unrelated user conversation as the bot's chat, and the hide sweep would then hide it. The existing "no pin" / "dead pin" safety tests cover exactly that and still pass. The pin keeps owning plumbing (creation, hide sweep, DM delivery); it just stops shadowing newer conversations. Guards on the candidate (`newerVisibleBotChat`): the canonical chat can never shadow itself, an empty draft never displaces a real conversation, and a gateway that omits `message_count` is treated as real history rather than discarded. 2. The workspace did not follow the bot. The three `host.openSession` calls on the bot path relied on the SDK default `keepAllProfilesScope: true`, so `$activeGatewayProfile` stayed on whatever profile was active before the click. Sessions created afterwards were then filed under the previous bot's profile — measured: four new chats started from three different bots all persisted into one profile's state.db. Clicking a bot IS a profile switch, so these pass `false`. Note on the call shape: `previewSession` is `bot.preferred_session || last`, so on a pinned bot it resolves to the PIN (preview identity must match click identity). Feeding that as the "newer" candidate makes the whole preference dead code — it always sees the pin and short-circuits on "same id". The freshest visible session therefore arrives as its own argument. The first attempt at this fix had that bug and passed its tests, which is why `bot-row-opens-latest.test.mjs` mirrors the production call site argument for argument rather than constructing a convenient one. Tests: 362 pass (was 348). Each new guard was verified by sabotage — reverting any one of the three behaviours above makes the suite fail (1, 3, and 1 tests respectively), so none of them is a test that passes either way.