List the last visited workspace first in the rail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -36,6 +36,10 @@ export const server: BunextPageServerFn = async ({ req }) => {
|
||||
name: AppData["LastVisitedBotCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
{
|
||||
name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
]);
|
||||
return new_res;
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user