List the last visited workspace first in the rail

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-28 19:48:38 +01:00
co-authored by Claude Opus 5.5
parent c1c866876e
commit d352ac924a
17 changed files with 190 additions and 20 deletions
+5
View File
@@ -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",
@@ -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;
}
@@ -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);
+2
View File
@@ -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<PagePropsType> = async ({ req, url }) => {
user_types,
install_name: await grabInstallName().catch(() => ""),
install_icon: await grabInstallIcon().catch(() => null),
last_visited_workspace_id: readLastVisitedWorkspace({ req }),
},
};
};
@@ -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<PagePropsType> = 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;
},
};
};
@@ -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<PagePropsType> = 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) });
},
};
};
@@ -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<PagePropsType> = 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) });
},
};
};
@@ -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<PagePropsType> = async (params) => {
workspace_activity,
workspace_tasks,
},
res_transform(res) {
return withLastVisitedWorkspaceCookies({ req: params.req, res, workspace_id });
},
};
};
+4
View File
@@ -36,6 +36,10 @@ export const server: BunextPageServerFn = async ({ req }) => {
name: AppData["LastVisitedBotCookieName"],
httpOnly: true,
},
{
name: AppData["LastVisitedWorkspaceCookieName"],
httpOnly: true,
},
]);
return new_res;
},
+5
View File
@@ -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.
*/
@@ -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"],
},
];
}
+21
View File
@@ -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]);
});
});
+23
View File
@@ -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)];
}
+3 -13
View File
@@ -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;
}
+20
View File
@@ -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;
}
+18
View File
@@ -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"]),
});
}