Update frontend UI/UX

This commit is contained in:
2026-10-20 04:59:08 +01:00
parent 59f5acb7bc
commit e2093b0df6
17 changed files with 163 additions and 11 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
import botRef from "@/src/utils/bot-ref";
/**
* Prompts the helper harnesses send. The text model gets their output in
* place of the media, so they must leave nothing out that it may need.
@@ -36,7 +38,7 @@ Be proactive. Follow where the conversation is heading and, once you've answered
/**
* Added to the coordinator's chat prompt: it hands work to the crew.
*/
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace.`;
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
+4
View File
@@ -14,6 +14,7 @@ import type { APIResponseObject } from "@moduletrace/bunext/types";
import grabMediaURL from "../media/grab-media-url";
import logEvent, { grabRequestIP } from "../logger";
import AppError from "@/src/utils/app-error";
import isSecureRequest from "@/src/utils/is-secure-request";
type Params = {
user_id: string | number;
@@ -106,17 +107,20 @@ export default async function loginUser({
});
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
const secure = req ? isSecureRequest({ req }) : true;
setCookies(res, [
{
name: AppData["AuthKeyCookieName"],
value: encrypted_key,
maxAge,
secure,
},
{
name: AppData["AuthCSRFCookieName"],
value: csrf_key,
maxAge,
secure,
},
]);
+2 -1
View File
@@ -6,6 +6,7 @@ import pushBotCreated from "@/src/bots-queue/push-bot-created";
import createBot from "@/src/functions/backend/db/bots/create-bot";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import pickFreeHue from "@/src/utils/pick-free-hue";
import botRef from "@/src/utils/bot-ref";
import type { HarnessTool } from "../../types";
type Params = {
@@ -96,7 +97,7 @@ export default function createBotTool({
const where = target?.name ? ` and joins ${target.name}` : "";
return `${args.name} is on the crew (bot #${bot_id})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."} assign_computer, add_bot_to_workspace and delegate_task take #${bot_id} right away, in this same reply.`;
return `${args.name} is on the crew (${botRef({ id: bot_id })})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."} assign_computer, add_bot_to_workspace and delegate_task take bot_id ${bot_id} right away, in this same reply.`;
},
};
}
+3 -2
View File
@@ -1,6 +1,7 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import botRef from "@/src/utils/bot-ref";
import type { HarnessTool } from "../../types";
type Args = {
@@ -17,7 +18,7 @@ type Args = {
export default function findBotTool(): HarnessTool<Args> {
return {
name: "find_bot",
description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's id, name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`,
description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's ref (its id, e.g. [bot:3]), name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`,
schema: z.object({
query: z.string().trim().max(200).optional(),
limit: z.number().int().min(1).max(50).optional(),
@@ -50,7 +51,7 @@ export default function findBotTool(): HarnessTool<Args> {
return matches
.map(
(bot) =>
`#${bot.id} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})`,
`${botRef({ id: bot.id || 0 })} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})`,
)
.join("\n");
},
@@ -0,0 +1,8 @@
import React from "react";
import type { BotWithLastMessage } from "@/src/types";
/**
* The live crew the conversation sees (the workspace's in a workspace),
* so a bot named in a message links to it, even one created just now.
*/
export const CrewBotsContext = React.createContext<BotWithLastMessage[]>([]);
@@ -0,0 +1,48 @@
import { useContext } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import { AppContext } from "@/src/pages/__root";
import { CrewBotsContext } from "../(data)/crew-bots-context";
type Props = {
bot_id: number;
};
/**
* A crew member a bot named in a message (`botRef`): a link to its
* conversation, in the open workspace when it's in the crew there, else
* its own chat. A bot the user doesn't reach is shown by name only.
*/
export default function BotRefChip({ bot_id }: Props) {
const { pageProps } = useContext(AppContext);
const workspace_id = pageProps?.workspace?.id;
const bots = useContext(CrewBotsContext);
const bot = bots.find((b) => b.id == bot_id);
if (!bot) {
return <Span className="font-bold">{`bot ${bot_id}`}</Span>;
}
return (
<Link
href={
workspace_id
? `/admin/workspaces/${workspace_id}/bots/${bot.id}`
: `/admin/bots/${bot.id}`
}
title={`Open ${bot.name}`}
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
>
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
label={bot.name || ""}
size={18}
/>
{bot.name}
</Link>
);
}
@@ -1,14 +1,15 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import BotRefChip from "./bot-ref-chip";
import ComputerRefChip from "./computer-ref-chip";
import TakeOverChip from "./take-over-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button, a take-over request a button that opens the
* computer; anything else opens in a new tab.
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -32,6 +33,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
}
}
if (href?.startsWith(BotRefHrefPrefix)) {
const bot_id = Number(href.slice(BotRefHrefPrefix.length));
if (bot_id) return <BotRefChip bot_id={bot_id} />;
}
return (
<Link
href={href}
@@ -10,6 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import { CrewBotsContext } from "../(data)/crew-bots-context";
import { BotMessageActionsContext } from "../(data)/message-actions-context";
import { SpeakMessageContext } from "../(data)/speak-message-context";
import reactToBotMessage from "../(functions)/react-to-bot-message";
@@ -156,6 +157,7 @@ export default function Conversation() {
if (!bot?.id || !live_bot) return null;
return (
<CrewBotsContext.Provider value={bots}>
<BotComputersContext.Provider value={bot_computers}>
<SpeakMessageContext.Provider value={speak}>
<BotMessageActionsContext.Provider
@@ -206,5 +208,6 @@ export default function Conversation() {
<ComputerViewerModal bot={live_bot} />
</SpeakMessageContext.Provider>
</BotComputersContext.Provider>
</CrewBotsContext.Provider>
);
}
+1
View File
@@ -112,6 +112,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
if (reauth) {
return await loginUser({
user_id: Number(user.id),
req,
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* How a bot names a crew member in a message: `[bot:3]`. The chat shows it
* as a link to that bot's conversation.
*/
export default function botRef({ id }: { id: number | string }) {
return `[bot:${id}]`;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import isSecureRequest from "./is-secure-request";
describe("isSecureRequest", () => {
test("plain HTTP on a VPN address is not secure", () => {
const req = new Request("http://10.8.0.1:7247/api/auth/password-login");
expect(isSecureRequest({ req })).toBe(false);
});
test("an HTTPS request is secure", () => {
const req = new Request("https://cobble.example.com/");
expect(isSecureRequest({ req })).toBe(true);
});
test("trusts the proxy's forwarded scheme", () => {
const req = new Request("http://localhost:7247/", {
headers: { "X-Forwarded-Proto": "https" },
});
expect(isSecureRequest({ req })).toBe(true);
});
test("reads the first of several forwarded schemes", () => {
const req = new Request("https://cobble.example.com/", {
headers: { "X-Forwarded-Proto": "http, https" },
});
expect(isSecureRequest({ req })).toBe(false);
});
});
+21
View File
@@ -0,0 +1,21 @@
type Params = {
req: Request;
};
/**
* Whether the browser reached the app over HTTPS: the request's own scheme,
* or the first `X-Forwarded-Proto` a proxy (Cloudflare, Caddy) set in front.
* Plain HTTP (a WireGuard address, a LAN IP) gets cookies without `Secure`,
* which the browser would otherwise drop.
*/
export default function isSecureRequest({ req }: Params): boolean {
const forwarded = req.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
if (forwarded) return forwarded === "https";
return new URL(req.url).protocol === "https:";
}
+9
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import botRef from "./bot-ref";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
import takeOverRef from "./take-over-ref";
@@ -25,4 +26,12 @@ describe("linkComputerRefs", () => {
}),
).toBe("Please log in to Gmail. [take over](#take-over-3-browser)");
});
test("links every bot a message names", () => {
expect(
linkComputerRefs({
text: `${botRef({ id: 4 })} is on it. See [bot:x] and [bot:5](https://example.com).`,
}),
).toBe("[bot 4](#bot-4) is on it. See [bot:x] and [bot:5](https://example.com).");
});
});
+9 -2
View File
@@ -12,9 +12,15 @@ export const ComputerRefHrefPrefix = "#computer-";
*/
export const TakeOverRefHrefPrefix = "#take-over-";
/**
* The link target a bot ref (`botRef`) becomes, e.g. `#bot-3`. The chat
* renders these as links to that bot's conversation.
*/
export const BotRefHrefPrefix = "#bot-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`) in a message into a markdown link, so it
* (`[take-over:3:browser]`) and bot ref (`[bot:3]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button.
*/
export default function linkComputerRefs({ text }: { text: string }) {
@@ -26,5 +32,6 @@ export default function linkComputerRefs({ text }: { text: string }) {
.replace(
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
);
)
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`);
}
+4
View File
@@ -10,6 +10,10 @@ describe("messagePreview", () => {
).toBe("Found it Your GitHub session is on a computer. See the docs and search_computers. one two");
});
test("names bots plainly", () => {
expect(messagePreview({ text: "[bot:4] is on it." })).toBe("a bot is on it.");
});
test("keeps code text without its fences", () => {
expect(messagePreview({ text: "Run:\n```bash\nls ~\n```" })).toBe("Run: ls ~");
});
+2 -1
View File
@@ -1,12 +1,13 @@
/**
* A bot message as one line of plain text, for the rail's preview: no
* markdown syntax, links as their text, computer refs (`computerRef`) as
* "a computer", whitespace collapsed.
* "a computer", bot refs (`botRef`) as "a bot", whitespace collapsed.
*/
export default function messagePreview({ text }: { text: string }) {
return text
.replace(/```[^\n]*\n?/g, "")
.replace(/\[computer:\d+\](?!\()/g, "a computer")
.replace(/\[bot:\d+\](?!\()/g, "a bot")
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
.replace(/(\*\*|__|~~|`)/g, "")