From d352ac924a2793b083e860409019acef2f360ccb Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 28 Oct 2026 19:48:38 +0100 Subject: [PATCH] List the last visited workspace first in the rail Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- src/data/app-data.ts | 5 ++++ .../with-last-visited-workspace-cookies.ts | 28 +++++++++++++++++++ .../(partials)/rail-workspaces-section.tsx | 15 +++++++--- src/pages/__root.server.ts | 2 ++ .../bots/[bot_id]/index.server.ts | 8 +++++- .../chatrooms/[chatroom_id]/index.server.ts | 4 +++ .../[workspace_id]/chatrooms/index.server.ts | 8 +++++- .../workspaces/[workspace_id]/index.server.ts | 4 +++ src/pages/auth/logout/index.server.ts | 4 +++ src/types/index.ts | 5 ++++ src/utils/last-visited-workspace-cookies.ts | 27 ++++++++++++++++++ src/utils/order-rail-workspaces.test.ts | 21 ++++++++++++++ src/utils/order-rail-workspaces.ts | 23 +++++++++++++++ src/utils/parse-last-visited-bot.ts | 16 ++--------- src/utils/parse-positive-id.ts | 20 +++++++++++++ src/utils/read-last-visited-workspace.ts | 18 ++++++++++++ 17 files changed, 190 insertions(+), 20 deletions(-) create mode 100644 src/functions/server/with-last-visited-workspace-cookies.ts create mode 100644 src/utils/last-visited-workspace-cookies.ts create mode 100644 src/utils/order-rail-workspaces.test.ts create mode 100644 src/utils/order-rail-workspaces.ts create mode 100644 src/utils/parse-positive-id.ts create mode 100644 src/utils/read-last-visited-workspace.ts diff --git a/AGENTS.md b/AGENTS.md index 60a989d..3101475 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`. - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening after a reply or a task (a session with no user message, work handed to the bot, is named from the bot's own messages; `grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. -- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. +- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. The workspace pages set `cobble_last_visited_workspace_id`, read in the root (`last_visited_workspace_id`), and the rail lists that workspace first (`orderRailWorkspaces`); it never decides the landing. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. - **No model:** while the bot has no text model there (`bot_text_model` null, kept live by the models modal), the thread shows `NoModelNotice` (centred when empty): super admins get Connect a provider / Guided setup, others are told the super admin sets it up. - **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. diff --git a/src/data/app-data.ts b/src/data/app-data.ts index f295a4e..e040fba 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -61,6 +61,11 @@ export const AppData = { * reads it. */ LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`, + /** + * The workspace this browser was last in: the rail lists it first. Never + * where `/admin/bots` lands. `HttpOnly`: only the server reads it. + */ + LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`, LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60, AuthExpiryDays: 60, APICrudBasePath: "/api/admin/crud", diff --git a/src/functions/server/with-last-visited-workspace-cookies.ts b/src/functions/server/with-last-visited-workspace-cookies.ts new file mode 100644 index 0000000..46d8ee0 --- /dev/null +++ b/src/functions/server/with-last-visited-workspace-cookies.ts @@ -0,0 +1,28 @@ +import { setCookies } from "@/src/utils/cookies"; +import isSecureRequest from "@/src/utils/is-secure-request"; +import lastVisitedWorkspaceCookies from "@/src/utils/last-visited-workspace-cookies"; + +type Params = { + req: Request; + res: Response; + workspace_id: number | string; +}; + +/** + * The page's response with the last-visited workspace cookie set, so the + * rail lists this workspace first from the next page on. Called from the + * workspace pages' `res_transform`. + */ +export default function withLastVisitedWorkspaceCookies({ req, res, workspace_id }: Params): Response { + const new_res = res.clone(); + + setCookies( + new_res, + lastVisitedWorkspaceCookies({ + workspace_id, + secure: isSecureRequest({ req }), + }), + ); + + return new_res; +} diff --git a/src/layouts/bots/(partials)/rail-workspaces-section.tsx b/src/layouts/bots/(partials)/rail-workspaces-section.tsx index df298d8..fa555ce 100644 --- a/src/layouts/bots/(partials)/rail-workspaces-section.tsx +++ b/src/layouts/bots/(partials)/rail-workspaces-section.tsx @@ -1,6 +1,8 @@ import { useContext, useState } from "react"; import Span from "@moduletrace/twui/layout/Span"; import { AppData } from "@/src/data/app-data"; +import { AppContext } from "@/src/pages/__root"; +import orderRailWorkspaces from "@/src/utils/order-rail-workspaces"; import type { WorkspaceWithActivity } from "@/src/types"; import { AddFlowContext } from "../(data)/add-flow-context"; import AllWorkspacesModal from "./all-workspaces-modal"; @@ -16,12 +18,17 @@ type Props = { }; /** - * The user's workspaces, with a "+" to create one: the latest - * `AppData.RailRecentWorkspaces`, then "See all workspaces" for the rest, - * which lists them all in a modal. + * The user's workspaces, with a "+" to create one: the last visited one + * first, then the rest up to `AppData.RailRecentWorkspaces`, then "See all + * workspaces" for the others, which lists them all in a modal. */ -export default function RailWorkspacesSection({ workspaces, selected_id }: Props) { +export default function RailWorkspacesSection({ workspaces: listed, selected_id }: Props) { const { openForm } = useContext(AddFlowContext); + const { pageProps } = useContext(AppContext); + const workspaces = orderRailWorkspaces({ + workspaces: listed, + first_id: pageProps?.last_visited_workspace_id, + }); const [open, setOpen] = useState(false); const shown = workspaces.slice(0, AppData.RailRecentWorkspaces); const hidden = workspaces.slice(AppData.RailRecentWorkspaces); diff --git a/src/pages/__root.server.ts b/src/pages/__root.server.ts index d8da684..9dd9404 100644 --- a/src/pages/__root.server.ts +++ b/src/pages/__root.server.ts @@ -8,6 +8,7 @@ import isUnknownSettingsPath from "../utils/is-unknown-settings-path"; import { SettingsDefaultPath } from "../dict/settings-pages-dict"; import grabInstallName from "../functions/backend/db/app-settings/grab-install-name"; import grabInstallIcon from "../functions/backend/db/app-settings/grab-install-icon"; +import readLastVisitedWorkspace from "../utils/read-last-visited-workspace"; /** * Routing for a local install. There are no public pages: @@ -84,6 +85,7 @@ const server: BunextPageServerFn = async ({ req, url }) => { user_types, install_name: await grabInstallName().catch(() => ""), install_icon: await grabInstallIcon().catch(() => null), + last_visited_workspace_id: readLastVisitedWorkspace({ req }), }, }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts index 1517a81..d7210e1 100644 --- a/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/bots/[bot_id]/index.server.ts @@ -1,6 +1,7 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props"; import withLastVisitedBotCookies from "@/src/functions/server/with-last-visited-bot-cookies"; +import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies"; import type { PagePropsType, PageQueryObject } from "@/src/types"; import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; @@ -37,11 +38,16 @@ export const server: BunextPageServerFn = async (params) => { } const bot_id = props.bot.id; + const workspace_id = props.workspace?.id; return { props, res_transform(res) { - return withLastVisitedBotCookies({ req: params.req, res, bot_id }); + const with_bot = withLastVisitedBotCookies({ req: params.req, res, bot_id }); + + return workspace_id + ? withLastVisitedWorkspaceCookies({ req: params.req, res: with_bot, workspace_id }) + : with_bot; }, }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/chatrooms/[chatroom_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/chatrooms/[chatroom_id]/index.server.ts index ba7fefe..f7ea716 100644 --- a/src/pages/admin/workspaces/[workspace_id]/chatrooms/[chatroom_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/chatrooms/[chatroom_id]/index.server.ts @@ -1,3 +1,4 @@ +import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies"; import userAuth from "@/src/functions/backend/auth/user-auth"; import grabChatroomMessages from "@/src/functions/backend/db/chatrooms/grab-chatroom-messages"; import grabUserChatroom from "@/src/functions/backend/db/chatrooms/grab-user-chatroom"; @@ -44,5 +45,8 @@ export const server: BunextPageServerFn = async (params) => { chatroom: { ...chatroom, unread: 0 }, chatroom_messages: await grabChatroomMessages({ chatroom_id: chatroom.id }), }, + res_transform(res) { + return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) }); + }, }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/chatrooms/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/chatrooms/index.server.ts index 9dcb641..acff4aa 100644 --- a/src/pages/admin/workspaces/[workspace_id]/chatrooms/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/chatrooms/index.server.ts @@ -1,3 +1,4 @@ +import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies"; import userAuth from "@/src/functions/backend/auth/user-auth"; import grabUserChatrooms from "@/src/functions/backend/db/chatrooms/grab-user-chatrooms"; import grabWorkspaceRailProps from "@/src/functions/backend/db/workspaces/grab-workspace-rail-props"; @@ -24,5 +25,10 @@ export const server: BunextPageServerFn = async (params) => { const chatrooms = await grabUserChatrooms({ user_id: user.id, workspace_id: Number(rail.workspace.id) }); - return { props: { ...rail, chatrooms } }; + return { + props: { ...rail, chatrooms }, + res_transform(res) { + return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id: Number(rail.workspace.id) }); + }, + }; }; diff --git a/src/pages/admin/workspaces/[workspace_id]/index.server.ts b/src/pages/admin/workspaces/[workspace_id]/index.server.ts index 71cb658..410eff7 100644 --- a/src/pages/admin/workspaces/[workspace_id]/index.server.ts +++ b/src/pages/admin/workspaces/[workspace_id]/index.server.ts @@ -1,3 +1,4 @@ +import withLastVisitedWorkspaceCookies from "@/src/functions/server/with-last-visited-workspace-cookies"; import userAuth from "@/src/functions/backend/auth/user-auth"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; import grabWorkspaceActivity from "@/src/functions/backend/db/workspaces/grab-workspace-activity"; @@ -54,5 +55,8 @@ export const server: BunextPageServerFn = async (params) => { workspace_activity, workspace_tasks, }, + res_transform(res) { + return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id }); + }, }; }; diff --git a/src/pages/auth/logout/index.server.ts b/src/pages/auth/logout/index.server.ts index b7cecd7..2360900 100644 --- a/src/pages/auth/logout/index.server.ts +++ b/src/pages/auth/logout/index.server.ts @@ -36,6 +36,10 @@ export const server: BunextPageServerFn = async ({ req }) => { name: AppData["LastVisitedBotCookieName"], httpOnly: true, }, + { + name: AppData["LastVisitedWorkspaceCookieName"], + httpOnly: true, + }, ]); return new_res; }, diff --git a/src/types/index.ts b/src/types/index.ts index 57f37c1..2f0d6e2 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1998,6 +1998,11 @@ export type PagePropsType = { * General, and the root's icon links). */ install_icon?: PwaInstallIcon | null; + /** + * The workspace this browser was last in (a cookie read in the root): + * the rail lists it first. + */ + last_visited_workspace_id?: number | null; /** * The release the server is running, for the General settings page. */ diff --git a/src/utils/last-visited-workspace-cookies.ts b/src/utils/last-visited-workspace-cookies.ts new file mode 100644 index 0000000..e4baa34 --- /dev/null +++ b/src/utils/last-visited-workspace-cookies.ts @@ -0,0 +1,27 @@ +import { AppData } from "@/src/data/app-data"; +import type { CookieOptions } from "./cookies"; + +type Params = { + workspace_id: number | string; + /** + * From `isSecureRequest`, so plain HTTP over a VPN still keeps it. + */ + secure: boolean; +}; + +/** + * The cookie that remembers the workspace this browser was last in, for + * `setCookies`: read back by `readLastVisitedWorkspace` in the root, so the + * rail lists that workspace first. It never decides where `/admin/bots` + * lands. `HttpOnly`, since only the server reads it. + */ +export default function lastVisitedWorkspaceCookies({ workspace_id, secure }: Params): CookieOptions[] { + return [ + { + name: AppData["LastVisitedWorkspaceCookieName"], + value: String(workspace_id), + secure, + maxAge: AppData["LastVisitedCookieMaxAgeSeconds"], + }, + ]; +} diff --git a/src/utils/order-rail-workspaces.test.ts b/src/utils/order-rail-workspaces.test.ts new file mode 100644 index 0000000..e5a4746 --- /dev/null +++ b/src/utils/order-rail-workspaces.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import type { WorkspaceWithActivity } from "@/src/types"; +import orderRailWorkspaces from "./order-rail-workspaces"; + +const workspaces = [1, 2, 3, 4].map((id) => ({ id, name: `W${id}` }) as WorkspaceWithActivity); + +function ids(list: WorkspaceWithActivity[]) { + return list.map((workspace) => workspace.id); +} + +describe("orderRailWorkspaces", () => { + test("the last visited workspace leads, the rest keep their order", () => { + expect(ids(orderRailWorkspaces({ workspaces, first_id: 3 }))).toEqual([3, 1, 2, 4]); + }); + + test("unchanged without one, or when it isn't listed", () => { + expect(ids(orderRailWorkspaces({ workspaces }))).toEqual([1, 2, 3, 4]); + expect(ids(orderRailWorkspaces({ workspaces, first_id: null }))).toEqual([1, 2, 3, 4]); + expect(ids(orderRailWorkspaces({ workspaces, first_id: 9 }))).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/src/utils/order-rail-workspaces.ts b/src/utils/order-rail-workspaces.ts new file mode 100644 index 0000000..259ce79 --- /dev/null +++ b/src/utils/order-rail-workspaces.ts @@ -0,0 +1,23 @@ +import type { WorkspaceWithActivity } from "@/src/types"; + +type Params = { + workspaces: WorkspaceWithActivity[]; + /** + * The workspace this browser was last in (`readLastVisitedWorkspace`). + */ + first_id?: number | null; +}; + +/** + * The rail's workspaces with the last visited one first, the rest in + * their order. Unchanged when it isn't among them. + */ +export default function orderRailWorkspaces({ workspaces, first_id }: Params): WorkspaceWithActivity[] { + if (!first_id) return workspaces; + + const first = workspaces.find((workspace) => Number(workspace.id) === Number(first_id)); + + if (!first) return workspaces; + + return [first, ...workspaces.filter((workspace) => workspace !== first)]; +} diff --git a/src/utils/parse-last-visited-bot.ts b/src/utils/parse-last-visited-bot.ts index c067b1e..b0439fa 100644 --- a/src/utils/parse-last-visited-bot.ts +++ b/src/utils/parse-last-visited-bot.ts @@ -1,3 +1,5 @@ +import parsePositiveId from "./parse-positive-id"; + export type LastVisitedBot = { bot_id: number; }; @@ -13,21 +15,9 @@ type Params = { * is the caller's check. */ export default function parseLastVisitedBot({ bot_id }: Params): LastVisitedBot | null { - const bot = parseId(bot_id); + const bot = parsePositiveId({ value: bot_id }); if (bot == null) return null; return { bot_id: bot }; } - -function parseId(value?: string | number | null): number | null { - if (value === undefined || value === null) return null; - - const raw = String(value).trim(); - - if (!/^\d+$/.test(raw)) return null; - - const id = Number(raw); - - return Number.isSafeInteger(id) && id > 0 ? id : null; -} diff --git a/src/utils/parse-positive-id.ts b/src/utils/parse-positive-id.ts new file mode 100644 index 0000000..8c294d1 --- /dev/null +++ b/src/utils/parse-positive-id.ts @@ -0,0 +1,20 @@ +type Params = { + value?: string | number | null; +}; + +/** + * A positive whole number from a cookie or query value, or null when it + * isn't one, so a hand-edited value never reaches a query. Surrounding + * whitespace is trimmed. + */ +export default function parsePositiveId({ value }: Params): number | null { + if (value === undefined || value === null) return null; + + const raw = String(value).trim(); + + if (!/^\d+$/.test(raw)) return null; + + const id = Number(raw); + + return Number.isSafeInteger(id) && id > 0 ? id : null; +} diff --git a/src/utils/read-last-visited-workspace.ts b/src/utils/read-last-visited-workspace.ts new file mode 100644 index 0000000..fdefa97 --- /dev/null +++ b/src/utils/read-last-visited-workspace.ts @@ -0,0 +1,18 @@ +import { AppData } from "@/src/data/app-data"; +import { getCookie } from "./cookies"; +import parsePositiveId from "./parse-positive-id"; + +type Params = { + req: Request; +}; + +/** + * The workspace this browser was last in, from the last-visited cookie + * (see `lastVisitedWorkspaceCookies`), or null. Only orders the rail, so a + * workspace the user no longer reaches just isn't in the list. + */ +export default function readLastVisitedWorkspace({ req }: Params): number | null { + return parsePositiveId({ + value: getCookie(req, AppData["LastVisitedWorkspaceCookieName"]), + }); +}