Compare commits

..
3 Commits
Author SHA1 Message Date
tbenandClaude Opus 5.5 07f87ce4ea Release v0.7.6
Release image / image (release) Canceled after 0s
CI / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 f84f0fd0d6 Name sessions opened by handed-off work, and hide empty ones from activity
A session the coordinator's delegation, a routine or a hook opened has
no user message and no reply, so it was never named and showed as
"Untitled conversation". Tasks now name their session too, and a session
with no user message is named from the bot's own messages. Until a
session is named, Latest activity shows what it opened with, and
sessions with no messages are left out of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
tbenandClaude Opus 5.5 b4b27dea10 Truncate long list row titles instead of overflowing on mobile
Stack lines its children up at the start, so a linked row title grew to
its full one-line width and pushed the card past the screen edge. The
link now fills the row and the title is a block, so it truncates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 07:05:34 +01:00
11 changed files with 101 additions and 22 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
### Bots, sessions, chat ### Bots, sessions, chat
- **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`. - **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 (`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. - **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. - **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. `/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.
- **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. - **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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cobble", "name": "cobble",
"version": "0.7.5", "version": "0.7.6",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
+4 -7
View File
@@ -7,6 +7,7 @@ import generateText from "@/src/harness/text/generate-text";
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope"; import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
import parseSessionName from "./parse-session-name"; import parseSessionName from "./parse-session-name";
import sessionNameTranscript from "./session-name-transcript"; import sessionNameTranscript from "./session-name-transcript";
import sessionReadyToName from "./session-ready-to-name";
type Params = { type Params = {
session_id: number | string; session_id: number | string;
@@ -16,7 +17,8 @@ type Params = {
}; };
/** /**
* Names a session once its first exchange is in: the bot writes the title * Names a session once its first exchange is in (or, with no user message,
* once the bot has written in it: work handed to it): the bot writes the title
* the rail and sessions list show and a short summary of it, from the * the rail and sessions list show and a short summary of it, from the
* session's first messages (however late the first reply came). The summary * session's first messages (however late the first reply came). The summary
* says how many messages it covers (`summary_message_count`), so * says how many messages it covers (`summary_message_count`), so
@@ -51,12 +53,7 @@ export default async function nameBotSession({
limit: AppData.BotSessionNameMessages, limit: AppData.BotSessionNameMessages,
}); });
if ( if (!sessionReadyToName({ messages })) return false;
!messages.some((message) => message.author == "user") ||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
) {
return false;
}
const transcript = sessionNameTranscript({ messages }); const transcript = sessionNameTranscript({ messages });
+3 -2
View File
@@ -235,9 +235,10 @@ export default async function processBotJob({
* Once the first exchange is sent, the bot names the session and * Once the first exchange is sent, the bot names the session and
* previews it. After the reply is pushed, so it never waits on it; * previews it. After the reply is pushed, so it never waits on it;
* a model that can't answer now leaves the session for the next * a model that can't answer now leaves the session for the next
* reply to name. * job to name. Tasks name too: a session the coordinator's
* delegation or a routine opened has no reply to name it.
*/ */
if (job.kind == "reply" && !in_room && completion.message?.session_id) { if (!in_room && completion.message?.session_id) {
/** /**
* The message's session, not the job's as it was claimed: a * The message's session, not the job's as it was claimed: a
* reply that moved the conversation on answered in the new one. * reply that moved the conversation on answered in the new one.
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import sessionReadyToName from "./session-ready-to-name";
describe("sessionReadyToName", () => {
test("waits for the bot to answer the user", () => {
expect(sessionReadyToName({ messages: [{ id: 1, author: "user", text: "Hi" }] })).toBe(false);
});
test("names a first exchange", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "user", text: "Hi" },
{ id: 2, author: "bot", text: "Hello" },
],
}),
).toBe(true);
});
test("names work handed to the bot, with no user message", () => {
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "Coordinator asked me to: **SEO lookup**" }],
}),
).toBe(true);
});
test("a carried summary alone is not the bot's answer", () => {
expect(
sessionReadyToName({
messages: [
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
{ id: 2, author: "user", text: "Continue here" },
],
}),
).toBe(false);
expect(
sessionReadyToName({
messages: [{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 }],
}),
).toBe(false);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
type Params = {
messages: SessionOpeningMessage[];
};
/**
* Whether a session's opening says enough to name it: the bot has said
* something of its own (a carried summary doesn't count), and, when the
* user wrote, after the user's first message. A session with no user
* message at all is work handed to the bot (the coordinator's delegations,
* routines, hooks), named from the bot's own messages.
*/
export default function sessionReadyToName({ messages }: Params) {
const has_bot_message = messages.some(
(message) => message.author == "bot" && !message.carried_from_session_id,
);
if (!has_bot_message) return false;
const first_user = messages.findIndex((message) => message.author == "user");
if (first_user < 0) return true;
return messages
.slice(first_user + 1)
.some((message) => message.author == "bot" && !message.carried_from_session_id);
}
+2 -2
View File
@@ -279,8 +279,8 @@ update_memory and update_instructions save to this workspace by default: what yo
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text: export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
{"title": "...", "summary": "..."} {"title": "...", "summary": "..."}
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps. - title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. With no user message, it is work handed to you (by the coordinator, a schedule or a hook): name that work. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`; - summary: what the user (or whoever handed you the work) wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
/** /**
* Asks the text model to carry a conversation into a new session when the * Asks the text model to carry a conversation into a new session when the
@@ -10,8 +10,8 @@ type Params = {
/** /**
* The user's latest conversations in a workspace, most recent first, each * The user's latest conversations in a workspace, most recent first, each
* with the bot it's with (its face as the user sees it), its message count and latest message. Empty * with the bot it's with (its face as the user sees it), its message count, first and latest message. Sessions
* sessions are left out unless active. * with no messages yet are left out, active or not.
*/ */
export default async function grabWorkspaceActivity({ export default async function grabWorkspaceActivity({
user_id, user_id,
@@ -28,11 +28,12 @@ export default async function grabWorkspaceActivity({
b.stone AS bot_stone, b.stone AS bot_stone,
${face.sql}, ${face.sql},
(SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count, (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text,
(SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at ASC, m.id ASC LIMIT 1) AS first_message_text
FROM bot_sessions s FROM bot_sessions s
JOIN bots b ON b.id = s.bot_id JOIN bots b ON b.id = s.bot_id
WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL WHERE s.workspace_id = ? AND s.user_id = ? AND s.chatroom_id IS NULL
AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)) AND EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id)
AND (b.kind = 'coordinator' OR EXISTS ( AND (b.kind = 'coordinator' OR EXISTS (
SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id SELECT 1 FROM workspace_bots wb WHERE wb.workspace_id = s.workspace_id AND wb.bot_id = s.bot_id
)) ))
@@ -32,7 +32,7 @@ type Props = {
*/ */
export default function SettingsListRow({ avatar, title, href, badge, title_icon, subtitle, children }: Props) { export default function SettingsListRow({ avatar, title, href, badge, title_icon, subtitle, children }: Props) {
const title_content = ( const title_content = (
<Span className="text-sm font-bold leading-5 text-ink truncate w-full"> <Span className="block text-sm font-bold leading-5 text-ink truncate w-full">
{title} {title}
{title_icon ? <Span className="ml-1.5 inline-flex align-middle">{title_icon}</Span> : null} {title_icon ? <Span className="ml-1.5 inline-flex align-middle">{title_icon}</Span> : null}
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null} {badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
@@ -47,7 +47,7 @@ export default function SettingsListRow({ avatar, title, href, badge, title_icon
<Link <Link
href={href} href={href}
title={title} title={title}
className="block min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm" className="block w-full min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
> >
{title_content} {title_content}
</Link> </Link>
@@ -1,5 +1,6 @@
import Span from "@moduletrace/twui/layout/Span"; import Span from "@moduletrace/twui/layout/Span";
import BotAvatar from "@/src/components/general/bot-avatar"; import BotAvatar from "@/src/components/general/bot-avatar";
import { AppData } from "@/src/data/app-data";
import type { BotFace } from "@/src/dict/bot-dict"; import type { BotFace } from "@/src/dict/bot-dict";
import botStoneProps from "@/src/utils/bot-stone-props"; import botStoneProps from "@/src/utils/bot-stone-props";
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row"; import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
@@ -13,14 +14,18 @@ type Props = {
}; };
/** /**
* One recent conversation in the workspace: the bot, the session's title, * One recent conversation in the workspace: the bot, the session's title
* a preview of its latest message and when it last moved. * (what it opened with until it's named), a preview of its latest message
* and when it last moved.
*/ */
export default function WorkspaceActivityRow({ workspace_id, item }: Props) { export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
const at = item.last_activity_at || item.created_at; const at = item.last_activity_at || item.created_at;
const preview = item.last_message_text const preview = item.last_message_text
? messagePreview({ text: item.last_message_text }) ? messagePreview({ text: item.last_message_text })
: ""; : "";
const opening = item.first_message_text
? messagePreview({ text: item.first_message_text }).slice(0, AppData.BotSessionTitleMaxChars)
: "";
return ( return (
<SettingsListRow <SettingsListRow
@@ -31,7 +36,7 @@ export default function WorkspaceActivityRow({ workspace_id, item }: Props) {
face={(item.bot_face || "idle") as BotFace} face={(item.bot_face || "idle") as BotFace}
/> />
} }
title={item.title || "Untitled conversation"} title={item.title || opening || "Untitled conversation"}
href={`/admin/workspaces/${workspace_id}/bots/${item.bot_id}`} href={`/admin/workspaces/${workspace_id}/bots/${item.bot_id}`}
badge={item.unread ? "New" : undefined} badge={item.unread ? "New" : undefined}
subtitle={`${item.bot_name || "A bot"}${preview ? ` · ${preview}` : ""}`} subtitle={`${item.bot_name || "A bot"}${preview ? ` · ${preview}` : ""}`}
+4
View File
@@ -275,6 +275,10 @@ export type WorkspaceWithActivity = BUN_SQLITE_COBBLE_WORKSPACES & {
*/ */
export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & { export type WorkspaceActivityItem = BUN_SQLITE_COBBLE_BOT_SESSIONS & {
message_count: number; message_count: number;
/**
* What the session opened with, shown while it has no title yet.
*/
first_message_text?: string | null;
last_message_text?: string | null; last_message_text?: string | null;
bot_name?: string | null; bot_name?: string | null;
bot_image_media_id?: number | null; bot_image_media_id?: number | null;