Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f87ce4ea | ||
|
|
f84f0fd0d6 | ||
|
|
b4b27dea10 | ||
|
|
596a417229 | ||
|
|
623112fd19 | ||
|
|
3e78d51b56 | ||
|
|
4e20106c59 | ||
|
|
c547e556ec | ||
|
|
74da091dd4 | ||
|
|
a33593460b | ||
|
|
bd73ecbe12 | ||
|
|
50dd6976ee |
@@ -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.
|
||||||
@@ -153,7 +153,7 @@ A secret URL, `POST /api/hooks/<token>` (`src/pages/api/hooks/[token].ts`, publi
|
|||||||
|
|
||||||
### Tasks (the to-do tracker)
|
### Tasks (the to-do tracker)
|
||||||
|
|
||||||
`tasks` (a bot's own list is `bot_id` alone; a workspace's task has `workspace_id` plus the `bot_id` that has it there, null while shared; a bot reaches its own list and its own plus shared tasks in its workspaces, a workspace admin all of that workspace's, `botTaskScopesSql`; a shared task becomes the bot's when it starts it; removing or deleting a bot makes its workspace tasks shared again), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
`tasks` (**every task belongs to a bot**, the coordinator included, never shared: a bot's own list is `bot_id` alone, a workspace's task has `workspace_id` plus the `bot_id` that has it there, enforced by `taskScopeValues`; a worker reaches only its own, a workspace admin all of that workspace's, the coordinator all, `botTaskScopesSql`; a bot removed from a workspace or deleted passes its tasks there to the workspace's admin bot, else the coordinator (`passOnBotTasks`, also run at boot for rows without a bot)), `task_notes`, `task_media` (deliverable|reference). Not the same as `bot_tasks`/`bots_queue` (those record jobs). Visibility SQL in `src/utils/task-access/`. Helpers in `src/functions/backend/db/tasks/`; tools `list_tasks`/`create_task`/`update_task`; API `api/admin/tasks/` (`userAuth` -> `isReadOnlyUser` -> `assertUserReachesTask` -> `safeErrorMessage`). UI in the workspace dashboard and the header's Tasks modal (shared components in `src/components/general/`); a card's title opens `TaskDetailModal` with everything about the task and its status moves (a modal over the list, never swapped in place). **Hand-over:** `handOverTask` (bot: `delegate_task` with `task_id`; UI: Hand over, crew managers) moves an open task to the new bot (a workspace's stays in the workspace, now the new bot's, and is worked there), sets it doing and queues a job with `params.tracker_task_id`; `noteTrackerTask` notes the job's end on it and blocks it on failure. **Not built yet:** task steps, `server:task-changed` pushes, a `/admin/tasks` page.
|
||||||
|
|
||||||
### Envs
|
### Envs
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
"version": "0.7.1",
|
"version": "0.7.6",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
import type { TaskDetail, TaskWithNames } from "@/src/types";
|
||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||||
|
import TaskDetailPanel from "./task-detail-panel";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The list row clicked; open while set. Its notes and files load when
|
||||||
|
* it opens, so the list stays small.
|
||||||
|
*/
|
||||||
|
task?: TaskWithNames | null;
|
||||||
|
/**
|
||||||
|
* A crew manager can change it, file onto it and delete it.
|
||||||
|
*/
|
||||||
|
can_manage?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: (params: { task: TaskDetail; msg?: string }) => void;
|
||||||
|
onDeleted: (params: { task_id: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task opened from a list in a modal of its own: everything about it, and
|
||||||
|
* for a crew manager its edit, hand-over, files and delete.
|
||||||
|
*/
|
||||||
|
export default function TaskDetailModal({ task, can_manage, onClose, onChanged, onDeleted }: Props) {
|
||||||
|
const [detail, set_detail] = useState<TaskDetail | null>(null);
|
||||||
|
const [error, set_error] = useState<string>();
|
||||||
|
const task_id = task?.id ? Number(task.id) : 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
set_detail(null);
|
||||||
|
set_error(undefined);
|
||||||
|
|
||||||
|
if (!task_id) return;
|
||||||
|
|
||||||
|
let live = true;
|
||||||
|
|
||||||
|
fetchApi<{}, APIResponseObject<TaskDetail>>(`/api/admin/tasks/get-task?task_id=${task_id}`, {
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.then((res) => {
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
if (!res?.success || !res.singleRes) {
|
||||||
|
set_error(res?.msg || "Couldn't open the task.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_detail(res.singleRes);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [task_id]);
|
||||||
|
|
||||||
|
const shown = detail || task;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open={Boolean(task_id)}
|
||||||
|
setOpen={(open) => !open && onClose()}
|
||||||
|
title={shown?.title || (task_id ? `Task ${task_id}` : "Task")}
|
||||||
|
description={shown ? taskPlaceLabel({ task: shown }) : undefined}
|
||||||
|
>
|
||||||
|
<Stack className="w-full mt-4 gap-0! items-stretch">
|
||||||
|
{detail ? (
|
||||||
|
<TaskDetailPanel
|
||||||
|
key={detail.id}
|
||||||
|
task={detail}
|
||||||
|
can_manage={can_manage}
|
||||||
|
onChanged={(saved, msg) => {
|
||||||
|
set_detail(saved);
|
||||||
|
onChanged({ task: saved, msg });
|
||||||
|
}}
|
||||||
|
onDeleted={() => onDeleted({ task_id })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{error || "Loading the task..."}
|
||||||
|
</Span>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useContext, useRef, useState } from "react";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
@@ -10,6 +10,14 @@ import TaskFileRow from "@/src/components/general/task-file-row";
|
|||||||
import HandOverForm from "@/src/components/general/hand-over-form";
|
import HandOverForm from "@/src/components/general/hand-over-form";
|
||||||
import TaskForm from "@/src/components/general/task-form";
|
import TaskForm from "@/src/components/general/task-form";
|
||||||
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
import TaskStatusChip from "@/src/components/general/task-status-chip";
|
||||||
|
import TaskStatusMoves from "@/src/components/general/task-status-moves";
|
||||||
|
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
|
import TaskFactRow from "@/src/components/general/task-fact-row";
|
||||||
|
import TaskNoteRow from "@/src/components/general/task-note-row";
|
||||||
|
import type { BotFace } from "@/src/dict/bot-dict";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import botStoneProps from "@/src/utils/bot-stone-props";
|
||||||
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
@@ -24,12 +32,14 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task opened: what it is, its notes oldest first, its files (each
|
* One task opened: its status (a crew manager moves it along), who has it and where, who added it and
|
||||||
* opening its bytes), a box to add a note, and edit or delete. A crew
|
* when it last moved, what it is, its files (each opening its bytes), its
|
||||||
|
* notes oldest first, a box to add a note, and edit or delete. A crew
|
||||||
* manager can file a new deliverable straight onto it, or hand an open
|
* manager can file a new deliverable straight onto it, or hand an open
|
||||||
* one to another bot, which starts on it.
|
* one to another bot, which starts on it.
|
||||||
*/
|
*/
|
||||||
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [handing_over, setHandingOver] = useState(false);
|
const [handing_over, setHandingOver] = useState(false);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
@@ -135,13 +145,21 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
<Stack className="w-full gap-4! items-stretch">
|
<Stack className="w-full gap-4! items-stretch">
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
<TaskStatusChip status={task.status} />
|
<TaskStatusChip status={task.status} />
|
||||||
<Span className="grow min-w-0 text-[13px] leading-[18px] text-ink-secondary truncate">
|
|
||||||
{task.workspace_id
|
|
||||||
? task.workspace_name || `Workspace ${task.workspace_id}`
|
|
||||||
: task.bot_name || `Bot ${task.bot_id}`}
|
|
||||||
</Span>
|
|
||||||
{can_manage ? (
|
{can_manage ? (
|
||||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
<TaskStatusMoves
|
||||||
|
status={task.status}
|
||||||
|
disabled={busy}
|
||||||
|
onMove={(next) =>
|
||||||
|
run({
|
||||||
|
path: "tasks/save-task",
|
||||||
|
body: { task_id: Number(task.id), status: next },
|
||||||
|
done: "Status changed.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{can_manage ? (
|
||||||
|
<Row className="ml-auto shrink-0 gap-1! flex-nowrap">
|
||||||
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
|
{(OpenTaskStatuses as readonly string[]).includes(task.status || "") ? (
|
||||||
<Button
|
<Button
|
||||||
title="Hand the task to another bot"
|
title="Hand the task to another bot"
|
||||||
@@ -200,6 +218,59 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
) : null}
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Stack className="w-full gap-1.5! items-stretch">
|
||||||
|
<TaskFactRow label="Who has it">
|
||||||
|
{task.bot_id ? (
|
||||||
|
<>
|
||||||
|
<BotAvatar
|
||||||
|
image_media_id={task.bot_image_media_id}
|
||||||
|
{...botStoneProps({ color: task.bot_color, stone: task.bot_stone, hue: task.bot_hue })}
|
||||||
|
face={(task.bot_face || "idle") as BotFace}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
<Span className="min-w-0 truncate">{task.bot_name || `Bot ${task.bot_id}`}</Span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Span className="text-ink-secondary">No bot</Span>
|
||||||
|
)}
|
||||||
|
</TaskFactRow>
|
||||||
|
<TaskFactRow label="Where">
|
||||||
|
<Span className="min-w-0 truncate">
|
||||||
|
{task.workspace_id
|
||||||
|
? task.workspace_name || `Workspace ${task.workspace_id}`
|
||||||
|
: `${task.bot_name || "The bot"}'s own list`}
|
||||||
|
</Span>
|
||||||
|
</TaskFactRow>
|
||||||
|
<TaskFactRow label="Added">
|
||||||
|
<Span className="min-w-0 truncate">
|
||||||
|
{[
|
||||||
|
task.created_by_bot_id
|
||||||
|
? `By ${task.created_by_bot_name || `bot ${task.created_by_bot_id}`}`
|
||||||
|
: task.created_by_user_id
|
||||||
|
? task.created_by_user_id == pageProps?.user?.id
|
||||||
|
? "By you"
|
||||||
|
: `By ${task.created_by_user_name || "a user"}`
|
||||||
|
: task.origin == "bot"
|
||||||
|
? "By a bot"
|
||||||
|
: "By hand",
|
||||||
|
task.created_at ? relativeTime({ at: Number(task.created_at) }) : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</Span>
|
||||||
|
</TaskFactRow>
|
||||||
|
{task.updated_at ? (
|
||||||
|
<TaskFactRow label="Last moved">
|
||||||
|
<Span>{relativeTime({ at: Number(task.updated_at) })}</Span>
|
||||||
|
</TaskFactRow>
|
||||||
|
) : null}
|
||||||
|
{task.completed_at ? (
|
||||||
|
<TaskFactRow label="Finished">
|
||||||
|
<Span>{relativeTime({ at: Number(task.completed_at) })}</Span>
|
||||||
|
</TaskFactRow>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{task.status == "blocked" && task.blocked_reason ? (
|
{task.status == "blocked" && task.blocked_reason ? (
|
||||||
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
|
<Span className="text-[13px] leading-[18px] text-ink">{`Waiting on you: ${task.blocked_reason}`}</Span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -209,7 +280,9 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack className="w-full gap-1! items-stretch">
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Files</Span>
|
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
|
||||||
|
{task.files.length ? `Files (${task.files.length})` : "Files"}
|
||||||
|
</Span>
|
||||||
{deliverables.length || references.length ? (
|
{deliverables.length || references.length ? (
|
||||||
<Stack className="w-full gap-0! items-stretch">
|
<Stack className="w-full gap-0! items-stretch">
|
||||||
{[...deliverables, ...references].map((file) => (
|
{[...deliverables, ...references].map((file) => (
|
||||||
@@ -253,18 +326,15 @@ export default function TaskDetailPanel({ task, can_manage, onChanged, onDeleted
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack className="w-full gap-1! items-stretch">
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">Notes</Span>
|
<Span className="text-[12px] font-bold leading-4 text-ink-secondary">
|
||||||
|
{task.notes.length ? `Notes (${task.notes.length})` : "Notes"}
|
||||||
|
</Span>
|
||||||
{task.notes.length ? (
|
{task.notes.length ? (
|
||||||
task.notes.map((entry) => (
|
<Stack className="w-full gap-0! items-stretch">
|
||||||
<Row key={entry.id} className="w-full gap-2! flex-nowrap items-start">
|
{task.notes.map((entry) => (
|
||||||
<Span className="shrink-0 text-[11px] leading-[18px] text-ink-secondary">
|
<TaskNoteRow key={entry.id} note={entry} />
|
||||||
{entry.author == "user" ? "You" : "Bot"}
|
))}
|
||||||
</Span>
|
</Stack>
|
||||||
<Span className="grow min-w-0 whitespace-pre-wrap text-[13px] leading-[18px] text-ink">
|
|
||||||
{entry.text}
|
|
||||||
</Span>
|
|
||||||
</Row>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">No notes yet.</Span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One fact about an opened task: a label, then its value.
|
||||||
|
*/
|
||||||
|
export default function TaskFactRow({ label, children }: Props) {
|
||||||
|
return (
|
||||||
|
<Row className="w-full gap-3! flex-nowrap items-center">
|
||||||
|
<Span className="w-24 shrink-0 text-[12px] leading-[18px] text-ink-secondary">{label}</Span>
|
||||||
|
<Row className="grow min-w-0 gap-2! flex-nowrap items-center text-[13px] leading-[18px] text-ink">
|
||||||
|
{children}
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import Input from "@moduletrace/twui/form/Input";
|
|||||||
import Select from "@moduletrace/twui/form/Select";
|
import Select from "@moduletrace/twui/form/Select";
|
||||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import CodeEditor from "@/src/components/general/code-editor";
|
||||||
import { AppData } from "@/src/data/app-data";
|
import { AppData } from "@/src/data/app-data";
|
||||||
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
||||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||||
@@ -17,6 +18,11 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
bot_id?: number | null;
|
bot_id?: number | null;
|
||||||
workspace_id?: number | null;
|
workspace_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Adding to a workspace without a `bot_id`: the bots there to pick who
|
||||||
|
* has it, since every task belongs to a bot.
|
||||||
|
*/
|
||||||
|
bots?: { id?: number | string | null; name?: string | null }[];
|
||||||
/**
|
/**
|
||||||
* Edits this task; adds one when not given.
|
* Edits this task; adds one when not given.
|
||||||
*/
|
*/
|
||||||
@@ -26,15 +32,30 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one task form: a title, the brief, the status and, while it's
|
* The one task form: a title, the brief (in the code editor), the status and, while it's
|
||||||
* blocked, what the user has to do. The workspace's tasks group and the
|
* blocked, what the user has to do. The workspace's tasks group and the
|
||||||
* conversation's tasks modal both add and edit with it.
|
* conversation's tasks modal both add and edit with it.
|
||||||
*/
|
*/
|
||||||
export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel }: Props) {
|
export default function TaskForm({
|
||||||
|
bot_id,
|
||||||
|
workspace_id,
|
||||||
|
bots = [],
|
||||||
|
task,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
const [title, setTitle] = useState(task?.title || "");
|
const [title, setTitle] = useState(task?.title || "");
|
||||||
const [description, setDescription] = useState(task?.description || "");
|
const [description, setDescription] = useState(task?.description || "");
|
||||||
const [status, setStatus] = useState<TaskStatus>((task?.status as TaskStatus) || "todo");
|
const [status, setStatus] = useState<TaskStatus>(
|
||||||
const [blocked_reason, setBlockedReason] = useState(task?.blocked_reason || "");
|
(task?.status as TaskStatus) || "todo",
|
||||||
|
);
|
||||||
|
const [blocked_reason, setBlockedReason] = useState(
|
||||||
|
task?.blocked_reason || "",
|
||||||
|
);
|
||||||
|
const pick_bot = !task && !bot_id && Boolean(workspace_id);
|
||||||
|
const [picked_bot_id, setPickedBotId] = useState<number | undefined>(
|
||||||
|
bots[0]?.id ? Number(bots[0].id) : undefined,
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
@@ -46,24 +67,30 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pick_bot && !picked_bot_id) {
|
||||||
|
setError("Pick the bot that has it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
|
||||||
const res = await fetchApi<Record<string, unknown>, APIResponseObject<TaskDetail>>(
|
const res = await fetchApi<
|
||||||
"/api/admin/tasks/save-task",
|
Record<string, unknown>,
|
||||||
{
|
APIResponseObject<TaskDetail>
|
||||||
method: "POST",
|
>("/api/admin/tasks/save-task", {
|
||||||
body: {
|
method: "POST",
|
||||||
task_id: task?.id ? Number(task.id) : undefined,
|
body: {
|
||||||
bot_id: task ? undefined : bot_id || undefined,
|
task_id: task?.id ? Number(task.id) : undefined,
|
||||||
workspace_id: task ? undefined : workspace_id || undefined,
|
bot_id: task ? undefined : bot_id || picked_bot_id || undefined,
|
||||||
title,
|
workspace_id: task ? undefined : workspace_id || undefined,
|
||||||
description,
|
title,
|
||||||
status,
|
description,
|
||||||
blocked_reason: status == "blocked" ? blocked_reason || null : null,
|
status,
|
||||||
},
|
blocked_reason:
|
||||||
|
status == "blocked" ? blocked_reason || null : null,
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@@ -77,25 +104,41 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Form onSubmit={submit} className="gap-3!">
|
<Form onSubmit={submit} className="gap-3!">
|
||||||
<Stack className="gap-3! items-stretch">
|
<Stack className="gap-6! items-stretch">
|
||||||
<Input
|
<Input
|
||||||
|
title="Title"
|
||||||
label="Title"
|
label="Title"
|
||||||
|
showLabel
|
||||||
value={title}
|
value={title}
|
||||||
changeHandler={setTitle}
|
changeHandler={setTitle}
|
||||||
placeholder="What needs doing?"
|
placeholder="What needs doing?"
|
||||||
maxLength={AppData.TaskTitleMaxLength}
|
maxLength={AppData.TaskTitleMaxLength}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
{pick_bot ? (
|
||||||
label="Brief"
|
<Select
|
||||||
value={description}
|
title="Who has it"
|
||||||
changeHandler={setDescription}
|
label="Who has it"
|
||||||
|
showLabel
|
||||||
|
options={bots.map((bot) => ({
|
||||||
|
title: bot.name || `Bot ${bot.id}`,
|
||||||
|
value: String(bot.id),
|
||||||
|
default: Number(bot.id) == picked_bot_id,
|
||||||
|
}))}
|
||||||
|
changeHandler={(value) => setPickedBotId(Number(value) || undefined)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<CodeEditor
|
||||||
|
mode="markdown"
|
||||||
|
className="h-[500px]"
|
||||||
|
title="Brief"
|
||||||
placeholder="What to do, where, and how you'll know it's done."
|
placeholder="What to do, where, and how you'll know it's done."
|
||||||
maxLength={AppData.TaskDescriptionMaxLength}
|
content={description}
|
||||||
rows={5}
|
info={`Up to ${AppData.TaskDescriptionMaxLength.toLocaleString()} characters.`}
|
||||||
disabled={loading}
|
onChange={setDescription}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
title="Status"
|
||||||
label="Status"
|
label="Status"
|
||||||
showLabel
|
showLabel
|
||||||
options={TaskStatuses.map((it) => ({
|
options={TaskStatuses.map((it) => ({
|
||||||
@@ -103,11 +146,15 @@ export default function TaskForm({ bot_id, workspace_id, task, onSaved, onCancel
|
|||||||
value: it.value,
|
value: it.value,
|
||||||
default: it.value == status,
|
default: it.value == status,
|
||||||
}))}
|
}))}
|
||||||
changeHandler={(value) => setStatus(String(value) as TaskStatus)}
|
changeHandler={(value) =>
|
||||||
|
setStatus(String(value) as TaskStatus)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{status == "blocked" ? (
|
{status == "blocked" ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
|
title="Waiting on the user for"
|
||||||
label="Waiting on the user for"
|
label="Waiting on the user for"
|
||||||
|
showLabel
|
||||||
value={blocked_reason}
|
value={blocked_reason}
|
||||||
changeHandler={setBlockedReason}
|
changeHandler={setBlockedReason}
|
||||||
placeholder="What the user has to do before this can go on."
|
placeholder="What the user has to do before this can go on."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
@@ -8,35 +9,41 @@ import { AppContext } from "@/src/pages/__root";
|
|||||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
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 { TaskStatuses } from "@/src/dict/task-dict";
|
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import relativeTime from "@/src/utils/relative-time";
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
import taskPlaceLabel from "@/src/utils/task-place-label";
|
import taskPlaceLabel from "@/src/utils/task-place-label";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import RowIconButton from "./row-icon-button";
|
||||||
import TaskStatusChip from "./task-status-chip";
|
import TaskStatusChip from "./task-status-chip";
|
||||||
|
import TaskStatusMoves from "./task-status-moves";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
task: TaskWithNames;
|
task: TaskWithNames;
|
||||||
/**
|
/**
|
||||||
* Opens the task to read its notes and files, and to edit it.
|
* Opens the task in its modal: everything about it, and for a crew
|
||||||
|
* manager its edit, hand-over, files and delete. The title opens it.
|
||||||
*/
|
*/
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
/**
|
/**
|
||||||
* Moves it to one status; only crew managers get the controls.
|
* Moves it to one status; only crew managers get the controls.
|
||||||
*/
|
*/
|
||||||
onStatusChange?: (status: string) => void;
|
onStatusChange?: (status: string) => void;
|
||||||
|
/**
|
||||||
|
* Deletes it after asking; only crew managers get the button.
|
||||||
|
*/
|
||||||
|
onDelete?: () => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task: whose it is (a bot's stone), its title and status, where it
|
* One task: whose it is (a bot's stone), its title and status, where it
|
||||||
* is and who has it, what it's blocked on, its latest note and how long ago it
|
* is and who has it, what it's blocked on, its latest note and how long ago it
|
||||||
* moved.
|
* moved. Its title opens it in a modal.
|
||||||
*/
|
*/
|
||||||
export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Props) {
|
export default function TaskListItem({ task, onOpen, onStatusChange, onDelete, busy }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||||
const done = task.status == "done" || task.status == "cancelled";
|
const done = task.status == "done" || task.status == "cancelled";
|
||||||
const moves = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
<Row className="w-full gap-3! flex-nowrap items-start px-3 py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
@@ -49,18 +56,27 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
|||||||
<Stack className="grow min-w-0 gap-0.5! items-stretch">
|
<Stack className="grow min-w-0 gap-0.5! items-stretch">
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
<Button
|
<Button
|
||||||
title={task.title || "Open the task"}
|
title={`Open ${task.title || `task ${task.id}`}`}
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="text"
|
color="text"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className={`grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline ${
|
className={twMerge(
|
||||||
done ? "text-ink-secondary! line-through" : "text-ink!"
|
"grow min-w-0 justify-start px-0! h-auto! py-0! text-sm! font-bold! leading-5! truncate text-left hover:bg-transparent! hover:underline",
|
||||||
}`}
|
done ? "text-ink-secondary! line-through" : "text-ink!",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
|
<Span className="truncate">{task.title || `Task ${task.id}`}</Span>
|
||||||
</Button>
|
</Button>
|
||||||
<TaskStatusChip status={task.status} />
|
<TaskStatusChip status={task.status} />
|
||||||
|
{can_manage && onDelete ? (
|
||||||
|
<RowIconButton
|
||||||
|
icon={Trash2}
|
||||||
|
title={`Delete ${task.title || `task ${task.id}`}`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onDelete}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
|
<Span className="shrink-0 text-xs leading-4 text-ink-secondary truncate">
|
||||||
@@ -81,25 +97,7 @@ export default function TaskListItem({ task, onOpen, onStatusChange, busy }: Pro
|
|||||||
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
|
<Span className="text-xs leading-4 text-ink-secondary line-clamp-2">{task.latest_note}</Span>
|
||||||
) : null}
|
) : null}
|
||||||
{can_manage && onStatusChange ? (
|
{can_manage && onStatusChange ? (
|
||||||
<Row className="gap-1! flex-nowrap items-center pt-0.5">
|
<TaskStatusMoves status={task.status} disabled={busy} onMove={onStatusChange} className="pt-0.5" />
|
||||||
{moves
|
|
||||||
.filter((it) => it.value != task.status)
|
|
||||||
.map((it) => (
|
|
||||||
<Button
|
|
||||||
key={it.value}
|
|
||||||
title={`Mark it ${it.title.toLowerCase()}`}
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => onStatusChange(it.value)}
|
|
||||||
className="px-1.5! text-[12px]"
|
|
||||||
>
|
|
||||||
{it.title}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import type { TaskNote } from "@/src/types";
|
||||||
|
import relativeTime from "@/src/utils/relative-time";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
note: TaskNote;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One note on an opened task: who wrote it (the user, or the bot by name),
|
||||||
|
* how long ago, and what it says.
|
||||||
|
*/
|
||||||
|
export default function TaskNoteRow({ note }: Props) {
|
||||||
|
const author = note.author == "user" ? "You" : note.bot_name || "Bot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full gap-0.5! items-stretch py-1.5 border-0 border-b border-solid border-hairline last:border-b-0">
|
||||||
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
|
<Span className="text-[12px] font-bold leading-4 text-ink">{author}</Span>
|
||||||
|
{note.created_at ? (
|
||||||
|
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||||
|
{relativeTime({ at: Number(note.created_at) })}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
<Span className="whitespace-pre-wrap text-[13px] leading-[18px] text-ink">{note.text}</Span>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Tag from "@moduletrace/twui/elements/Tag";
|
import Tag from "@moduletrace/twui/elements/Tag";
|
||||||
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
import { TaskStatuses, type TaskStatus } from "@/src/dict/task-dict";
|
||||||
|
|
||||||
@@ -14,15 +15,20 @@ export default function TaskStatusChip({ status = "todo" }: Props) {
|
|||||||
const value = (status || "todo") as TaskStatus;
|
const value = (status || "todo") as TaskStatus;
|
||||||
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
|
const title = TaskStatuses.find((it) => it.value == value)?.title || "To do";
|
||||||
|
|
||||||
const tone =
|
/**
|
||||||
value == "blocked"
|
* A `Tag` is a filled primary button by default (ink in light mode), so
|
||||||
? "text-ink border-ink"
|
* the chip clears its fill and sets its own text: only the outline speaks.
|
||||||
: value == "doing"
|
*/
|
||||||
? "text-ink border-hairline-strong"
|
|
||||||
: "text-ink-secondary border-hairline";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tag className={`shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid ${tone}`}>
|
<Tag
|
||||||
|
className={twMerge(
|
||||||
|
"shrink-0 px-2! py-0.5! text-[11px]! font-bold! leading-4! border border-solid",
|
||||||
|
"bg-transparent! dark:bg-transparent! hover:bg-transparent!",
|
||||||
|
value == "blocked" && "text-ink! dark:text-ink! border-ink",
|
||||||
|
value == "doing" && "text-ink! dark:text-ink! border-hairline-strong",
|
||||||
|
value != "blocked" && value != "doing" && "text-ink-secondary! dark:text-ink-secondary! border-hairline",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onMove: (status: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a task to one status: To do, Doing or Done.
|
||||||
|
*/
|
||||||
|
export default function TaskStatusMoveButton({ title, value, disabled, onMove }: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
title={`Mark it ${title.toLowerCase()}`}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onMove(value)}
|
||||||
|
className="px-1.5! text-[12px]"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { TaskStatuses } from "@/src/dict/task-dict";
|
||||||
|
import TaskStatusMoveButton from "./task-status-move-button";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The task's status now; its own button is left out.
|
||||||
|
*/
|
||||||
|
status?: string | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
onMove: (status: string) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MoveStatuses = TaskStatuses.filter((it) => ["todo", "doing", "done"].includes(it.value));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The buttons that move a task along (To do, Doing, Done), the one it's
|
||||||
|
* at left out. Shown to crew managers on a list row and in the task modal.
|
||||||
|
*/
|
||||||
|
export default function TaskStatusMoves({ status, disabled, onMove, className }: Props) {
|
||||||
|
return (
|
||||||
|
<Row className={twMerge("gap-1! flex-nowrap items-center", className)}>
|
||||||
|
{MoveStatuses.filter((it) => it.value != status).map((it) => (
|
||||||
|
<TaskStatusMoveButton
|
||||||
|
key={it.value}
|
||||||
|
title={it.title}
|
||||||
|
value={it.value}
|
||||||
|
disabled={disabled}
|
||||||
|
onMove={onMove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -220,7 +220,7 @@ You are working through a task on your own; the user isn't watching. Use your to
|
|||||||
*/
|
*/
|
||||||
export const TasksNote = `# Tasks
|
export const TasksNote = `# Tasks
|
||||||
|
|
||||||
Tasks are the crew's shared to-do list, one per bot and one per workspace. A workspace's tasks are seen by every bot working there, and the coordinator sees everything.
|
Tasks are the crew's to-do list. Every task belongs to one bot, on its own list or in a workspace. You see your own; a workspace's admin sees every task in that workspace, and the coordinator sees everything.
|
||||||
|
|
||||||
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
|
- list_tasks shows what's open: yours, a workspace's, or (as the coordinator) all of it. Check it before you plan work, so you don't start something already tracked.
|
||||||
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
|
- create_task adds one, with a brief: what to do, where, and what done looks like. Only add a task for work that will take more than this reply; never one for something you're doing now.
|
||||||
@@ -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
|
||||||
|
|||||||
+21
-1
@@ -160,6 +160,20 @@ export const PwaDefaultInstallIcon: PwaInstallIcon = {
|
|||||||
export const PwaCornerMarkInset = 0.2;
|
export const PwaCornerMarkInset = 0.2;
|
||||||
export const PwaCornerMarkScale = 0.22;
|
export const PwaCornerMarkScale = 0.22;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corner mark on the regular (rounded-square) icons, which aren't cut
|
||||||
|
* to a circle: larger and nearer the corner, clear of its rounding.
|
||||||
|
*/
|
||||||
|
export const PwaSquareCornerMarkInset = 0.16;
|
||||||
|
export const PwaSquareCornerMarkScale = 0.3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corner mark on a round favicon: larger, to stay readable at 32px,
|
||||||
|
* and still inside the circle.
|
||||||
|
*/
|
||||||
|
export const PwaFaviconCornerMarkInset = 0.15;
|
||||||
|
export const PwaFaviconCornerMarkScale = 0.32;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads must be at least this many pixels on their shorter side, and are
|
* Uploads must be at least this many pixels on their shorter side, and are
|
||||||
* kept as a square of the source size.
|
* kept as a square of the source size.
|
||||||
@@ -167,12 +181,18 @@ export const PwaCornerMarkScale = 0.22;
|
|||||||
export const PwaInstallIconMinSide = 512;
|
export const PwaInstallIconMinSide = 512;
|
||||||
export const PwaInstallIconSourceSide = 1024;
|
export const PwaInstallIconSourceSide = 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bump when `renderInstallIcons` draws differently: it's part of a set's
|
||||||
|
* version, so a re-saved icon gets a new URL past the `immutable` cache.
|
||||||
|
*/
|
||||||
|
export const PwaInstallIconRenderRevision = 4;
|
||||||
|
|
||||||
export type PwaInstallIconFile = {
|
export type PwaInstallIconFile = {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
/**
|
/**
|
||||||
* `plain` is the image without the mark (the settings preview);
|
* `plain` is the image without the mark (the settings preview);
|
||||||
* `favicon` never gets the mark (unreadable at 32px).
|
* `favicon` is cut to a circle, the mark kept inside it.
|
||||||
*/
|
*/
|
||||||
kind: "any" | "maskable" | "favicon" | "plain";
|
kind: "any" | "maskable" | "favicon" | "plain";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* A task's status. Tasks are the crew's shared to-do list, one per bot and
|
* A task's status. Tasks are the crew's to-do list, every task one bot's
|
||||||
* one per workspace, and every move through this list is a step in the
|
* (on its own list or in a workspace), and every move through this list is a step in the
|
||||||
* work: `blocked` is what a bot (or the user) sets when only the user can
|
* work: `blocked` is what a bot (or the user) sets when only the user can
|
||||||
* unblock it, and `done` is only ever set once the work was verified.
|
* unblock it, and `done` is only ever set once the work was verified.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||||
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||||
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
||||||
|
import passOnBotTasks from "@/src/functions/backend/db/tasks/pass-on-bot-tasks";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import { clearEnvCache } from "../envs/grab-all-envs";
|
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||||
import grabUserBot from "./grab-user-bot";
|
import grabUserBot from "./grab-user-bot";
|
||||||
@@ -40,19 +41,18 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
|||||||
);
|
);
|
||||||
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
const task_media_ids = await grabTaskMediaIds({ bot_id: bot.id! }).catch(() => []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Its tasks in workspaces belong to the workspace: they go to each one's
|
||||||
|
* admin bot (else the coordinator) instead of cascading away with its
|
||||||
|
* own list.
|
||||||
|
*/
|
||||||
|
await passOnBotTasks({ bot_id: bot.id, bot_name: bot.name });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly, since foreign keys are off outside the server process.
|
* Explicitly, since foreign keys are off outside the server process.
|
||||||
*/
|
*/
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
|
||||||
/**
|
|
||||||
* Its tasks in workspaces belong to the workspace: they stay, shared
|
|
||||||
* again, instead of cascading away with its own list.
|
|
||||||
*/
|
|
||||||
await BunSQLite.sql({
|
|
||||||
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE bot_id = ? AND workspace_id IS NOT NULL`,
|
|
||||||
values: [Date.now(), bot.id],
|
|
||||||
});
|
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
|
||||||
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
|
await BunSQLite.sql({ sql: `DELETE FROM bot_graphics WHERE bot_id = ?`, values: [bot.id] });
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import passOnBotTasks from "./pass-on-bot-tasks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* At boot: every task belongs to a bot. Workspace tasks left without one
|
||||||
|
* by earlier versions ("shared") go to the workspace's admin bot, else the
|
||||||
|
* coordinator.
|
||||||
|
*/
|
||||||
|
export default async function assignOwnerlessTasks() {
|
||||||
|
return await passOnBotTasks({});
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a task to a bot's own list or a workspace's, there for one of its
|
* Adds a task to a bot's own list or a workspace's, there for one of its
|
||||||
* bots (`bot_id` too) or shared. A user adds one to a bot they chat with or
|
* bots (`bot_id` too): every task belongs to a bot. A user adds one to a bot they chat with or
|
||||||
* a workspace they're in; a bot's run adds one to its own list or to a
|
* a workspace they're in; a bot's run adds one to its own list or to a
|
||||||
* workspace it works in (its tool checks that). A workspace's task can
|
* workspace it works in (its tool checks that). A workspace's task can
|
||||||
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
|
* only go to a bot working there. Caps a list at `TaskMaxPerScope` open and
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import grabWorkspaceAdmins from "../bot-types/grab-workspace-admins";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
workspace_id: number | string;
|
||||||
|
/**
|
||||||
|
* The bot leaving, never its own heir.
|
||||||
|
*/
|
||||||
|
except_bot_id?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who takes over a workspace task whose bot is gone: the workspace's admin
|
||||||
|
* bot, else the coordinator. Every task belongs to a bot, so none is left
|
||||||
|
* without one.
|
||||||
|
*/
|
||||||
|
export default async function grabTaskHeir({ workspace_id, except_bot_id }: Params) {
|
||||||
|
const admins = await grabWorkspaceAdmins({ workspace_id });
|
||||||
|
const admin = admins.find((bot) => Number(bot.id) != Number(except_bot_id || 0));
|
||||||
|
|
||||||
|
if (admin?.id) return admin;
|
||||||
|
|
||||||
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
|
||||||
|
sql: `SELECT * FROM bots WHERE kind = 'coordinator' ORDER BY id ASC LIMIT 1`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.payload?.[0] || null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_TASK_NOTES } from "@/db/types/db";
|
import type { TaskDetail, TaskNote } from "@/src/types";
|
||||||
import type { TaskDetail } from "@/src/types";
|
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import botFaceSql from "@/src/utils/bot-face-sql";
|
import botFaceSql from "@/src/utils/bot-face-sql";
|
||||||
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
|
import taskScopesSql from "@/src/utils/task-access/task-scopes-sql";
|
||||||
@@ -16,7 +15,8 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task with its notes (oldest first) and files, only if the user
|
* One task with its notes (oldest first, each with the bot that wrote it),
|
||||||
|
* its files and who added it, only if the user
|
||||||
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
|
* reaches the bot or workspace it belongs to (`taskScopesSql`, the same
|
||||||
* condition the lists use).
|
* condition the lists use).
|
||||||
*/
|
*/
|
||||||
@@ -26,10 +26,14 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
|
|||||||
|
|
||||||
const row_res = await BunSQLite.sql<TaskDetail>({
|
const row_res = await BunSQLite.sql<TaskDetail>({
|
||||||
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
|
sql: `SELECT t.*, b.name AS bot_name, b.kind AS bot_kind, b.hue AS bot_hue, b.color AS bot_color, b.stone AS bot_stone,
|
||||||
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name
|
b.image_media_id AS bot_image_media_id, ${face.sql}, w.name AS workspace_name,
|
||||||
|
cb.name AS created_by_bot_name,
|
||||||
|
NULLIF(TRIM(COALESCE(cu.first_name, '') || ' ' || COALESCE(cu.last_name, '')), '') AS created_by_user_name
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN bots b ON b.id = t.bot_id
|
LEFT JOIN bots b ON b.id = t.bot_id
|
||||||
LEFT JOIN workspaces w ON w.id = t.workspace_id
|
LEFT JOIN workspaces w ON w.id = t.workspace_id
|
||||||
|
LEFT JOIN bots cb ON cb.id = t.created_by_bot_id
|
||||||
|
LEFT JOIN users cu ON cu.id = t.created_by_user_id
|
||||||
WHERE t.id = ? AND ${access.sql}
|
WHERE t.id = ? AND ${access.sql}
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
values: [...face.values, Number(task_id), ...access.values],
|
values: [...face.values, Number(task_id), ...access.values],
|
||||||
@@ -42,8 +46,10 @@ export default async function grabTask({ user_id, task_id }: Params): Promise<Ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [notes_res, files] = await Promise.all([
|
const [notes_res, files] = await Promise.all([
|
||||||
BunSQLite.sql<BUN_SQLITE_COBBLE_TASK_NOTES>({
|
BunSQLite.sql<TaskNote>({
|
||||||
sql: `SELECT * FROM task_notes WHERE task_id = ? ORDER BY id ASC`,
|
sql: `SELECT n.*, nb.name AS bot_name FROM task_notes n
|
||||||
|
LEFT JOIN bots nb ON nb.id = n.bot_id
|
||||||
|
WHERE n.task_id = ? ORDER BY n.id ASC`,
|
||||||
values: [Number(task_id)],
|
values: [Number(task_id)],
|
||||||
}),
|
}),
|
||||||
grabTaskMedia({ task_id }),
|
grabTaskMedia({ task_id }),
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ type Params = {
|
|||||||
* Only the tasks this bot has (anywhere, unless narrowed).
|
* Only the tasks this bot has (anywhere, unless narrowed).
|
||||||
*/
|
*/
|
||||||
only_bot_id?: number | string | null;
|
only_bot_id?: number | string | null;
|
||||||
/**
|
|
||||||
* With `only_bot_id`: also the shared ones nobody has yet.
|
|
||||||
*/
|
|
||||||
with_unassigned?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Only one workspace's tasks.
|
* Only one workspace's tasks.
|
||||||
*/
|
*/
|
||||||
@@ -61,7 +57,6 @@ export default async function grabTasks({
|
|||||||
is_coordinator,
|
is_coordinator,
|
||||||
admin_workspace_ids,
|
admin_workspace_ids,
|
||||||
only_bot_id,
|
only_bot_id,
|
||||||
with_unassigned,
|
|
||||||
only_workspace_id,
|
only_workspace_id,
|
||||||
own_list,
|
own_list,
|
||||||
statuses,
|
statuses,
|
||||||
@@ -82,7 +77,7 @@ export default async function grabTasks({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (only_bot_id) {
|
if (only_bot_id) {
|
||||||
where.push(with_unassigned ? `(t.bot_id = ? OR t.bot_id IS NULL)` : `t.bot_id = ?`);
|
where.push(`t.bot_id = ?`);
|
||||||
values.push(Number(only_bot_id));
|
values.push(Number(only_bot_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Params = {
|
|||||||
task_id: number | string;
|
task_id: number | string;
|
||||||
/**
|
/**
|
||||||
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
|
* Where it goes: a bot's own list (`bot_id` alone), or a workspace,
|
||||||
* with the bot that has it there (none: unassigned).
|
* with the bot that has it there (always one).
|
||||||
*/
|
*/
|
||||||
bot_id?: number | string | null;
|
bot_id?: number | string | null;
|
||||||
workspace_id?: number | string | null;
|
workspace_id?: number | string | null;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_TASKS } from "@/db/types/db";
|
||||||
|
import addTaskNote from "./add-task-note";
|
||||||
|
import grabTaskHeir from "./grab-task-heir";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The bot leaving; none for tasks that have no bot at all.
|
||||||
|
*/
|
||||||
|
bot_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Only its tasks in this workspace; none for every workspace.
|
||||||
|
*/
|
||||||
|
workspace_id?: number | string | null;
|
||||||
|
/**
|
||||||
|
* Who is leaving, for the note on each task.
|
||||||
|
*/
|
||||||
|
bot_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives a leaving bot's workspace tasks (or ones with no bot) to the
|
||||||
|
* workspace's admin bot, else the coordinator, with a note on each. Its own
|
||||||
|
* list is left alone. Returns how many moved.
|
||||||
|
*/
|
||||||
|
export default async function passOnBotTasks({ bot_id, workspace_id, bot_name }: Params) {
|
||||||
|
const where = [`workspace_id IS NOT NULL`, bot_id ? `bot_id = ?` : `bot_id IS NULL`];
|
||||||
|
const values: number[] = bot_id ? [Number(bot_id)] : [];
|
||||||
|
|
||||||
|
if (workspace_id) {
|
||||||
|
where.push(`workspace_id = ?`);
|
||||||
|
values.push(Number(workspace_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_TASKS>({
|
||||||
|
sql: `SELECT * FROM tasks WHERE ${where.join(" AND ")}`,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
let moved = 0;
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const heir = await grabTaskHeir({ workspace_id: task.workspace_id!, except_bot_id: bot_id });
|
||||||
|
|
||||||
|
if (!heir?.id) continue;
|
||||||
|
|
||||||
|
await BunSQLite.sql({
|
||||||
|
sql: `UPDATE tasks SET bot_id = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
values: [Number(heir.id), Date.now(), Number(task.id)],
|
||||||
|
});
|
||||||
|
|
||||||
|
await addTaskNote({
|
||||||
|
task_id: task.id!,
|
||||||
|
text: bot_name ? `${bot_name} left; this is now ${heir.name}'s.` : `This is now ${heir.name}'s.`,
|
||||||
|
author: "user",
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import taskScopeValues from "./task-scope-values";
|
||||||
|
|
||||||
|
describe("taskScopeValues", () => {
|
||||||
|
test("a task on a bot's own list or in a workspace has its bot", () => {
|
||||||
|
expect(taskScopeValues({ bot_id: 3 })).toEqual({ bot_id: 3, workspace_id: null });
|
||||||
|
expect(taskScopeValues({ bot_id: "3", workspace_id: "11" })).toEqual({ bot_id: 3, workspace_id: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a workspace task without a bot is refused", () => {
|
||||||
|
expect(() => taskScopeValues({ workspace_id: 11 })).toThrow();
|
||||||
|
expect(() => taskScopeValues({})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,17 +6,17 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a task lives and who has it. A bot's own list is `bot_id` alone; a
|
* Where a task lives and who has it. Every task belongs to a bot: a bot's
|
||||||
* workspace's task has `workspace_id`, plus `bot_id` when a bot there has
|
* own list is `bot_id` alone, a workspace's task has `workspace_id` plus
|
||||||
* it (none: shared, not yet anyone's). Checked in the helpers rather than
|
* the `bot_id` that has it there. Checked in the helpers rather than the
|
||||||
* the schema.
|
* schema.
|
||||||
*/
|
*/
|
||||||
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
|
export default function taskScopeValues({ bot_id, workspace_id }: Params) {
|
||||||
const bot = bot_id ? Number(bot_id) : null;
|
const bot = bot_id ? Number(bot_id) : null;
|
||||||
const workspace = workspace_id ? Number(workspace_id) : null;
|
const workspace = workspace_id ? Number(workspace_id) : null;
|
||||||
|
|
||||||
if (!bot && !workspace) {
|
if (!bot) {
|
||||||
throw new AppError(`Say which bot or workspace the task is for.`);
|
throw new AppError(`Say which bot has the task.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bot_id: bot, workspace_id: workspace };
|
return { bot_id: bot, workspace_id: workspace };
|
||||||
|
|||||||
@@ -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
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import AppError from "@/src/utils/app-error";
|
import AppError from "@/src/utils/app-error";
|
||||||
import grabUserWorkspace from "./grab-user-workspace";
|
import grabUserWorkspace from "./grab-user-workspace";
|
||||||
|
import grabBotById from "../bots/grab-bot-by-id";
|
||||||
|
import passOnBotTasks from "../tasks/pass-on-bot-tasks";
|
||||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
@@ -31,12 +33,11 @@ export default async function removeBotFromWorkspace({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tasks it had there stay in the workspace, shared again.
|
* The tasks it had there stay in the workspace, passed to its admin bot
|
||||||
|
* (else the coordinator), read before its admin type goes below.
|
||||||
*/
|
*/
|
||||||
await BunSQLite.sql({
|
const bot = await grabBotById({ bot_id });
|
||||||
sql: `UPDATE tasks SET bot_id = NULL, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
|
await passOnBotTasks({ bot_id, workspace_id: workspace.id, bot_name: bot?.name });
|
||||||
values: [Date.now(), workspace.id, bot_id],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Its types there (workspace admin) go with its place.
|
* Its types there (workspace admin) go with its place.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
|
PwaInstallIconRenderRevision,
|
||||||
PwaInstallIconSettingKey,
|
PwaInstallIconSettingKey,
|
||||||
type PwaInstallIcon,
|
type PwaInstallIcon,
|
||||||
type PwaInstallIconMode,
|
type PwaInstallIconMode,
|
||||||
@@ -57,7 +58,7 @@ export default async function saveInstallIcon({ mode, stone, mark, image }: Para
|
|||||||
const version = is_stock
|
const version = is_stock
|
||||||
? null
|
? null
|
||||||
: createHash("sha256")
|
: createHash("sha256")
|
||||||
.update(JSON.stringify({ mode, stone, mark }))
|
.update(JSON.stringify({ mode, stone, mark, render: PwaInstallIconRenderRevision }))
|
||||||
.update(source || "")
|
.update(source || "")
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import sharp from "sharp";
|
import sharp, { type OverlayOptions } from "sharp";
|
||||||
import {
|
import {
|
||||||
PwaAppIconMarkScale,
|
PwaAppIconMarkScale,
|
||||||
PwaCornerMarkInset,
|
PwaCornerMarkInset,
|
||||||
PwaCornerMarkScale,
|
PwaCornerMarkScale,
|
||||||
|
PwaFaviconCornerMarkInset,
|
||||||
|
PwaFaviconCornerMarkScale,
|
||||||
PwaInstallIconFiles,
|
PwaInstallIconFiles,
|
||||||
PwaMaskableMarkScale,
|
PwaMaskableMarkScale,
|
||||||
|
PwaSquareCornerMarkInset,
|
||||||
|
PwaSquareCornerMarkScale,
|
||||||
type PwaInstallIcon,
|
type PwaInstallIcon,
|
||||||
type PwaInstallIconFile,
|
type PwaInstallIconFile,
|
||||||
} from "@/src/data/pwa-data";
|
} from "@/src/data/pwa-data";
|
||||||
@@ -26,7 +30,8 @@ type Params = {
|
|||||||
/**
|
/**
|
||||||
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
|
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
|
||||||
* A stone icon is the stone on paper, like the stock one. An image fills
|
* A stone icon is the stone on paper, like the stock one. An image fills
|
||||||
* the icon, with the stone in the top-left corner when `mark` is on.
|
* the icon, with the stone in the top-left corner when `mark` is on; its
|
||||||
|
* favicon is cut to a circle, the mark kept inside it.
|
||||||
*/
|
*/
|
||||||
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
|
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
|
||||||
const files: Record<string, Buffer> = {};
|
const files: Record<string, Buffer> = {};
|
||||||
@@ -79,19 +84,41 @@ async function renderImageFile({
|
|||||||
.resize(file.size, file.size, { fit: "cover" })
|
.resize(file.size, file.size, { fit: "cover" })
|
||||||
.flatten({ background });
|
.flatten({ background });
|
||||||
|
|
||||||
if (!mark || file.kind == "favicon" || file.kind == "plain") {
|
const layers: OverlayOptions[] = [];
|
||||||
return base.png().toBuffer();
|
|
||||||
|
if (mark && file.kind != "plain") {
|
||||||
|
const { scale, inset_scale } = markPlacement({ kind: file.kind });
|
||||||
|
const mark_size = Math.max(1, Math.round(file.size * scale));
|
||||||
|
const inset = Math.round(file.size * inset_scale);
|
||||||
|
const stone = await sharp(stone_svg)
|
||||||
|
.resize(mark_size, mark_size, { fit: "contain" })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
layers.push({ input: stone, left: inset, top: inset });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mark_size = Math.max(1, Math.round(file.size * PwaCornerMarkScale));
|
if (file.kind == "favicon") {
|
||||||
const inset = Math.round(file.size * PwaCornerMarkInset);
|
const half = file.size / 2;
|
||||||
const stone = await sharp(stone_svg)
|
const circle = Buffer.from(
|
||||||
.resize(mark_size, mark_size, { fit: "contain" })
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
|
||||||
.png()
|
);
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
return base
|
layers.push({ input: circle, blend: "dest-in" });
|
||||||
.composite([{ input: stone, left: inset, top: inset }])
|
}
|
||||||
.png()
|
|
||||||
.toBuffer();
|
// Layers apply in order, so the circle cuts the mark too.
|
||||||
|
return base.ensureAlpha().composite(layers).png().toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
|
||||||
|
if (kind == "favicon") {
|
||||||
|
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind == "any") {
|
||||||
|
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,19 +39,18 @@ type Args = {
|
|||||||
description: string;
|
description: string;
|
||||||
bot_id?: number;
|
bot_id?: number;
|
||||||
workspace_id?: number;
|
workspace_id?: number;
|
||||||
shared?: boolean;
|
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Place = { bot_id?: number; workspace_id?: number };
|
type Place = { bot_id: number; workspace_id?: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worked out from the arguments and who is asking: where the task goes and
|
* Worked out from the arguments and who is asking: where the task goes and
|
||||||
* who has it, or why it can't. Outside a workspace a worker adds only to
|
* who has it, or why it can't. Every task belongs to a bot. Outside a
|
||||||
* its own list. In one the task stays in that workspace: its own by
|
* workspace a worker adds only to its own list. In one the task stays in
|
||||||
* default, a member's as the workspace's admin, or shared (nobody's yet).
|
* that workspace: its own by default, or a member's as the workspace's
|
||||||
* The coordinator adds to any worker's own list or any workspace, for one
|
* admin. The coordinator adds to any worker's own list or any workspace,
|
||||||
* of its bots or shared.
|
* for one of its bots or itself.
|
||||||
*/
|
*/
|
||||||
function resolvePlace({
|
function resolvePlace({
|
||||||
args,
|
args,
|
||||||
@@ -69,22 +68,18 @@ function resolvePlace({
|
|||||||
const target_bot = args.bot_id ? Number(args.bot_id) : null;
|
const target_bot = args.bot_id ? Number(args.bot_id) : null;
|
||||||
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
|
const target_workspace = args.workspace_id ? Number(args.workspace_id) : null;
|
||||||
|
|
||||||
if (args.shared && target_bot) {
|
|
||||||
return `A shared task is nobody's yet: leave bot_id out, or drop shared to give it to that bot.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_coordinator) {
|
if (is_coordinator) {
|
||||||
const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
|
const place_workspace = target_workspace || (workspace_id ? Number(workspace_id) : null);
|
||||||
|
|
||||||
if (place_workspace) {
|
if (place_workspace) {
|
||||||
return target_bot ? { workspace_id: place_workspace, bot_id: target_bot } : { workspace_id: place_workspace };
|
return { workspace_id: place_workspace, bot_id: target_bot || Number(bot_id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bot_id: target_bot || Number(bot_id) };
|
return { bot_id: target_bot || Number(bot_id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace_id) {
|
if (!workspace_id) {
|
||||||
if (target_workspace || args.shared) {
|
if (target_workspace) {
|
||||||
return `You're not in a workspace here, so a task can only go on your own list (bot_id ${bot_id}).`;
|
return `You're not in a workspace here, so a task can only go on your own list (bot_id ${bot_id}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +94,12 @@ function resolvePlace({
|
|||||||
return `You can only add tasks in this workspace (#${workspace_id}).`;
|
return `You can only add tasks in this workspace (#${workspace_id}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.shared) return { workspace_id: Number(workspace_id) };
|
|
||||||
|
|
||||||
const is_member = members.some((member) => member.id == target_bot);
|
const is_member = members.some((member) => member.id == target_bot);
|
||||||
|
|
||||||
if (target_bot && target_bot != Number(bot_id) && !is_member) {
|
if (target_bot && target_bot != Number(bot_id) && !is_member) {
|
||||||
return members.length
|
return members.length
|
||||||
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}), or leave it shared.`
|
? `You can only give a task here to yourself (#${bot_id}) or a member (${members.map((m) => m.id).join(", ")}).`
|
||||||
: `You can only give a task here to yourself (#${bot_id}), or leave it shared.`;
|
: `You can only give a task here to yourself (#${bot_id}).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
|
return { workspace_id: Number(workspace_id), bot_id: target_bot || Number(bot_id) };
|
||||||
@@ -114,8 +107,8 @@ function resolvePlace({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets a bot add a task to a list it works on: its own, or in a workspace
|
* Lets a bot add a task to a list it works on: its own, or in a workspace
|
||||||
* its own there (a member's as its admin) or a shared one. The coordinator
|
* its own there (a member's as its admin). The coordinator can add to any
|
||||||
* can add to any worker's list and any workspace.
|
* worker's list and any workspace. Every task belongs to a bot.
|
||||||
*/
|
*/
|
||||||
export default function createTaskTool({
|
export default function createTaskTool({
|
||||||
user_id,
|
user_id,
|
||||||
@@ -134,8 +127,8 @@ export default function createTaskTool({
|
|||||||
return {
|
return {
|
||||||
name: "create_task",
|
name: "create_task",
|
||||||
description: is_coordinator
|
description: is_coordinator
|
||||||
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id) or shared (without), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it a shared task in this workspace" : ""}). Write the description as a brief: what to do, where, and how you'll know it's done.`
|
? `Add a task: to a worker's own list (bot_id: ${crew || "no workers yet"}), to a workspace (workspace_id: ${places || "no workspaces yet"}) for one of its bots (with bot_id; without, it's yours there), or to your own list (leave both ids out${workspace_id ? "; here, leaving them out makes it yours in this workspace" : ""}). Every task belongs to one bot. Write the description as a brief: what to do, where, and how you'll know it's done.`
|
||||||
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, a member's as this workspace's admin (bot_id: ${team})` : ""}, or shared (shared: true) for whichever bot here takes it up` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
: `Add a task: ${workspace_id ? `in this workspace, yours by default${team ? `, or a member's as this workspace's admin (bot_id: ${team})` : ""}` : `on your own list (bot_id ${bot_id}, the default)`}. Write the description as a brief: what to do, where, and how you'll know it's done.`,
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
|
title: z.string().trim().min(1).max(AppData.TaskTitleMaxLength).describe(`A short label`),
|
||||||
description: z
|
description: z
|
||||||
@@ -144,7 +137,7 @@ export default function createTaskTool({
|
|||||||
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
|
.describe(`The brief, in markdown: what to do, where, and what done looks like`),
|
||||||
bot_id: z.number().int().optional().describe(
|
bot_id: z.number().int().optional().describe(
|
||||||
is_coordinator
|
is_coordinator
|
||||||
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out for your own list or a shared workspace task.`
|
? `Who has it: a worker's own list, or with workspace_id a bot working there. Leave out to have it yourself.`
|
||||||
: team
|
: team
|
||||||
? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
|
? `Who has it here: you (${bot_id}, the default) or a member (${team}).`
|
||||||
: `Your own id (${bot_id}); leaving it out has the same effect.`,
|
: `Your own id (${bot_id}); leaving it out has the same effect.`,
|
||||||
@@ -158,10 +151,6 @@ export default function createTaskTool({
|
|||||||
? `The workspace it's in, when it isn't a bot's own list.`
|
? `The workspace it's in, when it isn't a bot's own list.`
|
||||||
: `This workspace (${workspace_id || "none"}); leaving it out has the same effect.`,
|
: `This workspace (${workspace_id || "none"}); leaving it out has the same effect.`,
|
||||||
),
|
),
|
||||||
shared: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(`In a workspace: nobody's yet, for whichever bot there takes it up. Defaults to false.`),
|
|
||||||
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
|
status: z.enum(TaskStatusValues).optional().describe(`Defaults to to do`),
|
||||||
}),
|
}),
|
||||||
run: async ({ args }) => {
|
run: async ({ args }) => {
|
||||||
@@ -201,7 +190,7 @@ export default function createTaskTool({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const where = place.workspace_id
|
const where = place.workspace_id
|
||||||
? `in workspace ${place.workspace_id}, ${!place.bot_id ? "shared" : place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
|
? `in workspace ${place.workspace_id}, ${place.bot_id == Number(bot_id) ? "yours" : `bot ${place.bot_id}'s`}`
|
||||||
: place.bot_id == Number(bot_id)
|
: place.bot_id == Number(bot_id)
|
||||||
? "to your list"
|
? "to your list"
|
||||||
: `to bot ${place.bot_id}'s list`;
|
: `to bot ${place.bot_id}'s list`;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
|
* The task tools every run gets (`src/harness/tools/bot/tasks/`): the
|
||||||
* to-do list (a bot's own, and in each workspace its tasks there and the
|
* to-do list (every task belongs to one bot: its own list and its tasks in
|
||||||
* shared ones), so a chat reply, a task and a scheduled run all read it,
|
* each workspace; an admin's whole workspace), so a chat reply, a task and a scheduled run all read it,
|
||||||
* move it along and file what they produced. The coordinator's create_task
|
* move it along and file what they produced. The coordinator's create_task
|
||||||
* may target any worker or workspace; its update_task may link any file it
|
* may target any worker or workspace; its update_task may link any file it
|
||||||
* can reach. A workspace admin's may give tasks there to the other members.
|
* can reach. A workspace admin's may give tasks there to the other members.
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ type Args = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lets a bot read the tasks it can work on: its own list and every
|
* Lets a bot read the tasks it can work on: its own (its list and what it
|
||||||
* workspace it works in, or for the coordinator the whole install. Used
|
* has in workspaces), every task of a workspace it's admin of, or for the
|
||||||
|
* coordinator the whole install. Used
|
||||||
* both to plan work and to remind the user of what's pending.
|
* both to plan work and to remind the user of what's pending.
|
||||||
*/
|
*/
|
||||||
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
|
export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_workspace_ids, workspace_id }: Params): HarnessTool<Args> {
|
||||||
return {
|
return {
|
||||||
name: "list_tasks",
|
name: "list_tasks",
|
||||||
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and in your workspaces the shared ones nobody has yet)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
|
description: `List tasks: yours (your own list and the ones you have in workspaces), the ones in ${workspace_id ? "this workspace" : "a workspace"}, or ${is_coordinator ? "every task in the install" : "every task you can reach (yours, and as a workspace's admin every task there)"}. Each line is its id, status, title, whose it is, its files and its latest note. Open ones come first (blocked, then doing, then to do). Use it to plan work, and to check what's still pending when the user says hello or asks what's next.`,
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
scope: z
|
scope: z
|
||||||
.enum(["mine", "workspace", "all"])
|
.enum(["mine", "workspace", "all"])
|
||||||
@@ -74,7 +75,7 @@ export default function listTasksTool({ bot_id, user_id, is_coordinator, admin_w
|
|||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
.map((task) => {
|
.map((task) => {
|
||||||
const who = task.bot_id ? task.bot_name || `bot ${task.bot_id}` : "shared, nobody's yet";
|
const who = task.bot_name || `bot ${task.bot_id}`;
|
||||||
const where = task.workspace_id
|
const where = task.workspace_id
|
||||||
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
|
? `in ${names.get(Number(task.workspace_id)) || `workspace ${task.workspace_id}`}, ${who}`
|
||||||
: `on ${who}'s list`;
|
: `on ${who}'s list`;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { type TaskStatus, TaskStatusValues } from "@/src/dict/task-dict";
|
|||||||
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
|
import addTaskNote from "@/src/functions/backend/db/tasks/add-task-note";
|
||||||
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
|
import attachTaskFile from "@/src/functions/backend/db/tasks/attach-task-file";
|
||||||
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
|
import grabRunTask from "@/src/functions/backend/db/tasks/grab-run-task";
|
||||||
import moveTask from "@/src/functions/backend/db/tasks/move-task";
|
|
||||||
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
|
import grabTaskMedia from "@/src/functions/backend/db/tasks/grab-task-media";
|
||||||
import updateTask from "@/src/functions/backend/db/tasks/update-task";
|
import updateTask from "@/src/functions/backend/db/tasks/update-task";
|
||||||
import type { StorageScope } from "@/src/utils/storage-access";
|
import type { StorageScope } from "@/src/utils/storage-access";
|
||||||
@@ -41,7 +40,7 @@ type Args = {
|
|||||||
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
|
export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_workspace_ids, scopes = [] }: Params): HarnessTool<Args> {
|
||||||
return {
|
return {
|
||||||
name: "update_task",
|
name: "update_task",
|
||||||
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Setting a shared workspace task to doing makes it yours. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
|
description: `Move a task along. Set its status (blocked needs a reason: what the user has to do), log a note of what you did or found, and file the files it produced. Call it as the work advances, not only at the end; every note is what the user sees on the task. Mark done only once you've verified the work (see the check note): starting a task isn't finishing it. To file a deliverable, save it first with write_file, make_pdf or generate_image, then pass its [file:ID] number.`,
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
task_id: z.number().int(),
|
task_id: z.number().int(),
|
||||||
status: z.enum(TaskStatusValues).optional(),
|
status: z.enum(TaskStatusValues).optional(),
|
||||||
@@ -68,15 +67,6 @@ export default function updateTaskTool({ user_id, bot_id, is_coordinator, admin_
|
|||||||
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids });
|
const task = await grabRunTask({ task_id: args.task_id, bot_id, is_coordinator, admin_workspace_ids });
|
||||||
const said: string[] = [];
|
const said: string[] = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* A shared task in a workspace is nobody's until a bot starts on
|
|
||||||
* it: then it's that bot's.
|
|
||||||
*/
|
|
||||||
if (args.status == "doing" && task.workspace_id && !task.bot_id && !is_coordinator) {
|
|
||||||
await moveTask({ task_id: task.id!, bot_id, workspace_id: task.workspace_id });
|
|
||||||
said.push(`it's yours now`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.status || args.blocked_reason !== undefined) {
|
if (args.status || args.blocked_reason !== undefined) {
|
||||||
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
|
if (args.status == "blocked" && !args.blocked_reason?.trim()) {
|
||||||
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
|
return `Blocked needs a reason: say in blocked_reason what the user has to do.`;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const MessageMarkdownComponents: Components = {
|
|||||||
</MarkdownList>
|
</MarkdownList>
|
||||||
),
|
),
|
||||||
code: ({ children }) => (
|
code: ({ children }) => (
|
||||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink break-words">
|
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-sunken text-ink wrap-anywhere">
|
||||||
{children}
|
{children}
|
||||||
</Span>
|
</Span>
|
||||||
),
|
),
|
||||||
@@ -58,7 +58,7 @@ export const MessageMarkdownComponents: Components = {
|
|||||||
),
|
),
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
<Stack className="w-full max-w-full overflow-x-auto items-start">
|
<Stack className="w-full max-w-full overflow-x-auto items-start">
|
||||||
<table className="text-[13px] leading-5 text-ink border-collapse">{children}</table>
|
<table className="text-[13px] leading-5 text-ink border-collapse wrap-break-word">{children}</table>
|
||||||
</Stack>
|
</Stack>
|
||||||
),
|
),
|
||||||
th: ({ children }) => (
|
th: ({ children }) => (
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const UserMarkdownComponents: Components = {
|
|||||||
</MarkdownList>
|
</MarkdownList>
|
||||||
),
|
),
|
||||||
code: ({ children }) => (
|
code: ({ children }) => (
|
||||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
|
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink wrap-anywhere">
|
||||||
{children}
|
{children}
|
||||||
</Span>
|
</Span>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type Params = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A conversation's tasks: a bot's own list, or in a workspace the ones it
|
* A conversation's tasks: a bot's own list, or in a workspace the ones it
|
||||||
* has there plus the shared ones nobody has yet. Loaded on open and refreshed after every change,
|
* has there. Loaded on open and refreshed after every change,
|
||||||
* never part of the page props.
|
* never part of the page props.
|
||||||
*/
|
*/
|
||||||
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
|
export default function useBotTasks({ bot_id, workspace_id, enabled = true }: Params) {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
<Stack
|
<Stack
|
||||||
{...{ [QuotableAttribute]: "" }}
|
{...{ [QuotableAttribute]: "" }}
|
||||||
className="w-full gap-2.5! items-start break-words"
|
className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full"
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={RemarkPlugins}
|
remarkPlugins={RemarkPlugins}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function BotRefChip({ bot_id }: Props) {
|
|||||||
: `/admin/bots/${bot.id}`
|
: `/admin/bots/${bot.id}`
|
||||||
}
|
}
|
||||||
title={`Open ${bot.name}`}
|
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"
|
className="inline-flex h-6 max-w-full 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
|
<BotAvatar
|
||||||
image_media_id={bot.image_media_id}
|
image_media_id={bot.image_media_id}
|
||||||
@@ -43,9 +43,10 @@ export default function BotRefChip({ bot_id }: Props) {
|
|||||||
face={(bot.face || "idle") as BotFace}
|
face={(bot.face || "idle") as BotFace}
|
||||||
label={bot.name || ""}
|
label={bot.name || ""}
|
||||||
size={18}
|
size={18}
|
||||||
|
className="shrink-0"
|
||||||
/>
|
/>
|
||||||
{bot.name}
|
<Span className="min-w-0 truncate">{bot.name}</Span>
|
||||||
{workspace_id && bot.is_admin ? <AdminShield size={13} /> : null}
|
{workspace_id && bot.is_admin ? <AdminShield size={13} className="shrink-0" /> : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
|||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
|
import type { BotWithLastMessage, TaskDetail, TaskWithNames } from "@/src/types";
|
||||||
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
|
import TaskDetailModal from "@/src/components/general/task-detail-modal";
|
||||||
import TaskForm from "@/src/components/general/task-form";
|
import TaskForm from "@/src/components/general/task-form";
|
||||||
import TaskListItem from "@/src/components/general/task-list-item";
|
import TaskListItem from "@/src/components/general/task-list-item";
|
||||||
|
|
||||||
@@ -31,15 +31,16 @@ type Props = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The tasks this conversation works on: a bot's own list, or in a workspace
|
* The tasks this conversation works on: a bot's own list, or in a workspace
|
||||||
* the ones it has there plus the shared ones nobody has yet. Open it from the header menu; the list loads
|
* the ones it has there. Open it from the header menu; the list loads
|
||||||
* then, so the page's props stay small. A crew manager adds, moves, edits
|
* then, so the page's props stay small. A crew manager adds, moves, edits
|
||||||
* and deletes; a task opens to show its notes and files.
|
* and deletes; a card opens the task in a modal of its own with everything
|
||||||
|
* about it.
|
||||||
*/
|
*/
|
||||||
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
|
export default function BotTasksModal({ bot, workspace, menu, on_open }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [adding, set_adding] = useState(false);
|
const [adding, set_adding] = useState(false);
|
||||||
const [open_task, set_open_task] = useState<TaskDetail | null>(null);
|
const [open_task, set_open_task] = useState<TaskWithNames | null>(null);
|
||||||
const [busy, set_busy] = useState(false);
|
const [busy, set_busy] = useState(false);
|
||||||
const [status, set_status] = useState<string>();
|
const [status, set_status] = useState<string>();
|
||||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||||
@@ -80,24 +81,27 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
replace({ task: res.singleRes });
|
replace({ task: res.singleRes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||||
|
if (busy) return;
|
||||||
|
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||||
|
|
||||||
|
set_busy(true);
|
||||||
set_status(undefined);
|
set_status(undefined);
|
||||||
|
|
||||||
/**
|
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||||
* The list already has the task; the detail endpoint serves its
|
"/api/admin/tasks/delete-task",
|
||||||
* notes and files.
|
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||||
*/
|
);
|
||||||
const detail = await fetchApi<{}, APIResponseObject<TaskDetail>>(
|
|
||||||
`/api/admin/tasks/get-task?task_id=${task.id}`,
|
|
||||||
{ method: "GET" },
|
|
||||||
).catch(() => null);
|
|
||||||
|
|
||||||
if (!detail?.success || !detail.singleRes) {
|
set_busy(false);
|
||||||
set_status(detail?.msg || "Couldn't open the task.");
|
|
||||||
|
if (!res.success) {
|
||||||
|
set_status(res.msg || "That didn't work.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set_open_task(detail.singleRes);
|
setTasks((prev) => prev.filter((item) => item.id != task.id));
|
||||||
|
set_status("Task deleted.");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -136,72 +140,30 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
<Stack className="w-full gap-1! items-stretch">
|
<Stack className="w-full gap-1! items-stretch">
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||||
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||||
{open_task ? open_task.title || "Task" : title}
|
{title}
|
||||||
</H2>
|
</H2>
|
||||||
{open_task ? (
|
{can_manage ? (
|
||||||
<Button
|
|
||||||
title="Back to the list"
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
onClick={() => set_open_task(null)}
|
|
||||||
className="px-2! text-[12px]"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
) : can_manage ? (
|
|
||||||
<Button
|
<Button
|
||||||
title="Add a task"
|
title="Add a task"
|
||||||
type="button"
|
type="button"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => set_adding(true)}
|
||||||
set_adding(true);
|
|
||||||
set_open_task(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Add task
|
Add task
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
{!open_task ? (
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
{workspace?.id
|
||||||
{workspace?.id
|
? `What ${name} has in ${workspace.name || "this workspace"}. The workspace shows every bot's.`
|
||||||
? `What ${name} has in ${workspace.name || "this workspace"}, and the shared tasks there nobody has taken up yet. The workspace shows every bot's.`
|
: `What ${name} is working through, and what it's waiting on.`}
|
||||||
: `What ${name} is working through, and what it's waiting on.`}
|
</Span>
|
||||||
</Span>
|
|
||||||
) : null}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
|
<Stack className="w-full mt-4 gap-0! items-stretch overflow-y-auto max-h-[55vh]">
|
||||||
{open_task ? (
|
{adding ? (
|
||||||
<TaskDetailPanel
|
|
||||||
task={open_task}
|
|
||||||
can_manage={can_manage}
|
|
||||||
onChanged={(saved, msg) => {
|
|
||||||
set_status(msg);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handed to another bot, it's that bot's now.
|
|
||||||
*/
|
|
||||||
if (saved.bot_id && saved.bot_id != bot.id) {
|
|
||||||
setTasks((prev) => prev.filter((item) => item.id != saved.id));
|
|
||||||
set_open_task(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set_open_task(saved);
|
|
||||||
replace({ task: saved });
|
|
||||||
}}
|
|
||||||
onDeleted={() => {
|
|
||||||
setTasks((prev) => prev.filter((item) => item.id != open_task.id));
|
|
||||||
set_open_task(null);
|
|
||||||
set_status("Task deleted.");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : adding ? (
|
|
||||||
<TaskForm
|
<TaskForm
|
||||||
bot_id={Number(bot.id)}
|
bot_id={Number(bot.id)}
|
||||||
workspace_id={workspace?.id ? Number(workspace.id) : null}
|
workspace_id={workspace?.id ? Number(workspace.id) : null}
|
||||||
@@ -218,8 +180,9 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onOpen={() => show({ task }).catch(() => {})}
|
onOpen={() => set_open_task(task)}
|
||||||
onStatusChange={(next) => move({ task, next })}
|
onStatusChange={(next) => move({ task, next })}
|
||||||
|
onDelete={() => remove({ task }).catch(() => {})}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -234,6 +197,31 @@ export default function BotTasksModal({ bot, workspace, menu, on_open }: Props)
|
|||||||
{status}
|
{status}
|
||||||
</Span>
|
</Span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<TaskDetailModal
|
||||||
|
task={open_task}
|
||||||
|
can_manage={can_manage}
|
||||||
|
onClose={() => set_open_task(null)}
|
||||||
|
onChanged={({ task: saved, msg }) => {
|
||||||
|
set_status(msg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handed to another bot, it's that bot's now.
|
||||||
|
*/
|
||||||
|
if (saved.bot_id && saved.bot_id != bot.id) {
|
||||||
|
setTasks((prev) => prev.filter((item) => item.id != saved.id));
|
||||||
|
set_open_task(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace({ task: saved });
|
||||||
|
}}
|
||||||
|
onDeleted={({ task_id }) => {
|
||||||
|
setTasks((prev) => prev.filter((item) => item.id != task_id));
|
||||||
|
set_open_task(null);
|
||||||
|
set_status("Task deleted.");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ export default function ComputerRefChip({ computer_id }: Props) {
|
|||||||
openComputer({ computer_id: computer.id || 0, mode: "browser" })
|
openComputer({ computer_id: computer.id || 0, mode: "browser" })
|
||||||
}
|
}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
|
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold whitespace-nowrap text-ink dark:text-ink",
|
||||||
|
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||||
current
|
current
|
||||||
? "bg-sunken hover:bg-sunken outline-0"
|
? "bg-sunken hover:bg-sunken outline-0"
|
||||||
: "bg-transparent hover:bg-sunken",
|
: "bg-transparent hover:bg-sunken",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Monitor size={14} aria-hidden className="shrink-0" />
|
<Monitor size={14} aria-hidden className="shrink-0" />
|
||||||
{name}
|
<Span className="min-w-0 truncate">{name}</Span>
|
||||||
{current ? <Check size={14} aria-hidden className="shrink-0" /> : null}
|
{current ? <Check size={14} aria-hidden className="shrink-0" /> : null}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
function CrewMessageLine({ message }: Props) {
|
function CrewMessageLine({ message }: Props) {
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full gap-2.5! items-start break-words">
|
<Stack className="w-full gap-2.5! items-start wrap-anywhere [&>*]:max-w-full">
|
||||||
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
||||||
{linkComputerRefs({ text: message.text || "" })}
|
{linkComputerRefs({ text: message.text || "" })}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Modal from "@moduletrace/twui/elements/Modal";
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import H2 from "@moduletrace/twui/layout/H2";
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
@@ -43,13 +44,16 @@ export default function HookProposalRefChip({ proposal_id }: Props) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size="smaller"
|
size="smaller"
|
||||||
title={`Review: ${title}`}
|
title={`Review: ${title}`}
|
||||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
className={twMerge(
|
||||||
|
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||||
|
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Webhook size={14} aria-hidden className="shrink-0" />
|
<Webhook size={14} aria-hidden className="shrink-0" />
|
||||||
{`${title} · ${status}`}
|
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
className="max-w-xl bg-paper p-6"
|
className="max-w-xl bg-paper p-6"
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
|||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-ink underline underline-offset-2 hover:opacity-80 break-words"
|
className="text-ink underline underline-offset-2 hover:opacity-80 wrap-anywhere"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function MarkdownList({ children, ordered, start, className }: Pr
|
|||||||
start={start}
|
start={start}
|
||||||
items={items}
|
items={items}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
|
`self-stretch gap-1! pl-5 my-0 text-sm leading-5 text-ink [&>li]:max-w-full ${ordered ? "list-decimal" : "list-disc"}`,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Modal from "@moduletrace/twui/elements/Modal";
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import H2 from "@moduletrace/twui/layout/H2";
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
@@ -47,13 +48,16 @@ export default function ProposalRefChip({ proposal_id }: Props) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size="smaller"
|
size="smaller"
|
||||||
title={`Review: ${title}`}
|
title={`Review: ${title}`}
|
||||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
className={twMerge(
|
||||||
|
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||||
|
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={14} aria-hidden className="shrink-0" />
|
<Icon size={14} aria-hidden className="shrink-0" />
|
||||||
{`${title} · ${status}`}
|
<Span className="min-w-0 truncate">{`${title} · ${status}`}</Span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
className="max-w-xl bg-paper p-6"
|
className="max-w-xl bg-paper p-6"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Modal from "@moduletrace/twui/elements/Modal";
|
import Modal from "@moduletrace/twui/elements/Modal";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import H2 from "@moduletrace/twui/layout/H2";
|
import H2 from "@moduletrace/twui/layout/H2";
|
||||||
@@ -46,13 +47,16 @@ export default function SecretRequestRefChip({ request_id }: Props) {
|
|||||||
color="gray"
|
color="gray"
|
||||||
size="smaller"
|
size="smaller"
|
||||||
title={view.status == "pending" ? `Enter ${view.key}` : `${view.key}: ${status}`}
|
title={view.status == "pending" ? `Enter ${view.key}` : `${view.key}: ${status}`}
|
||||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
className={twMerge(
|
||||||
|
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||||
|
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<KeyRound size={14} aria-hidden className="shrink-0" />
|
<KeyRound size={14} aria-hidden className="shrink-0" />
|
||||||
{`${view.key} · ${status}`}
|
<Span className="min-w-0 truncate">{`${view.key} · ${status}`}</Span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
targetWrapperProps={{ className: "inline-flex align-middle" }}
|
targetWrapperProps={{ className: "inline-flex max-w-full align-middle" }}
|
||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
className="max-w-xl bg-paper p-6"
|
className="max-w-xl bg-paper p-6"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import { Hand } from "lucide-react";
|
import { Hand } from "lucide-react";
|
||||||
@@ -34,10 +35,13 @@ export default function TakeOverChip({ computer_id, mode }: Props) {
|
|||||||
size="smaller"
|
size="smaller"
|
||||||
title={`Open ${name} (${mode_title}) and take over`}
|
title={`Open ${name} (${mode_title}) and take over`}
|
||||||
onClick={() => openComputer({ computer_id, mode, take_over: true })}
|
onClick={() => openComputer({ computer_id, mode, take_over: true })}
|
||||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
|
className={twMerge(
|
||||||
|
"inline-flex h-6 max-w-full gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap",
|
||||||
|
"[&_.twui-button-content-wrapper]:min-w-0",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Hand size={14} aria-hidden className="shrink-0" />
|
<Hand size={14} aria-hidden className="shrink-0" />
|
||||||
{`Take over ${name} · ${mode_title}`}
|
<Span className="min-w-0 truncate">{`Take over ${name} · ${mode_title}`}</Span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function UserBubble({ text }: { text: string }) {
|
|||||||
{...{ [QuotableAttribute]: "" }}
|
{...{ [QuotableAttribute]: "" }}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg",
|
"max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg",
|
||||||
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words",
|
"bg-ink text-on-ink px-4 py-3 text-sm leading-5 wrap-anywhere",
|
||||||
"conversation-user-bubble",
|
"conversation-user-bubble",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function UserMarkdownLink(props: ComponentProps<"a">) {
|
|||||||
href={props.href}
|
href={props.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 break-words"
|
className="text-blue-400 dark:text-blue-600 underline underline-offset-2 hover:opacity-80 wrap-anywhere"
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ export default function WorkspaceRefChip({ workspace_id }: Props) {
|
|||||||
<Link
|
<Link
|
||||||
href={`/admin/workspaces/${workspace.id}`}
|
href={`/admin/workspaces/${workspace.id}`}
|
||||||
title={`Open ${workspace.name}`}
|
title={`Open ${workspace.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"
|
className="inline-flex h-6 max-w-full 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"
|
||||||
>
|
>
|
||||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
|
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
|
||||||
{workspace.name}
|
<Span className="min-w-0 truncate">{workspace.name}</Span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ type Props = {
|
|||||||
/**
|
/**
|
||||||
* The app icon as a launcher would show it, laid out like the generated
|
* The app icon as a launcher would show it, laid out like the generated
|
||||||
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
|
* files (`renderInstallIcons`): a stone at 52% (maskable) or 80% on paper,
|
||||||
* or the image with the stone 20% in from the top-left at 22% of the side.
|
* or the image with the stone in the top-left: 20% in at 22% of the side
|
||||||
|
* (maskable, round), 16% in at 30% (rounded square).
|
||||||
*/
|
*/
|
||||||
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
|
export default function InstallIconShape({ shape, mode, stone, mark, image_src, className }: Props) {
|
||||||
const stone_src = `/brand/icons/${stone}.svg`;
|
const stone_src = `/brand/icons/${stone}.svg`;
|
||||||
@@ -41,7 +42,12 @@ export default function InstallIconShape({ shape, mode, stone, mark, image_src,
|
|||||||
<Img
|
<Img
|
||||||
src={stone_src}
|
src={stone_src}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute left-[20%] top-[20%] size-[22%]"
|
className={twMerge(
|
||||||
|
"absolute",
|
||||||
|
shape == "circle"
|
||||||
|
? "left-[20%] top-[20%] size-[22%]"
|
||||||
|
: "left-[16%] top-[16%] size-[30%]",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}` : ""}`}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Row from "@moduletrace/twui/layout/Row";
|
|||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import type { TaskDetail, TaskWithNames } from "@/src/types";
|
import type { TaskDetail, TaskWithNames } from "@/src/types";
|
||||||
import TaskDetailPanel from "@/src/components/general/task-detail-panel";
|
import TaskDetailModal from "@/src/components/general/task-detail-modal";
|
||||||
import TaskForm from "@/src/components/general/task-form";
|
import TaskForm from "@/src/components/general/task-form";
|
||||||
import TaskListItem from "@/src/components/general/task-list-item";
|
import TaskListItem from "@/src/components/general/task-list-item";
|
||||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
@@ -22,20 +22,25 @@ type Props = {
|
|||||||
* Its tasks, open first.
|
* Its tasks, open first.
|
||||||
*/
|
*/
|
||||||
tasks: TaskWithNames[];
|
tasks: TaskWithNames[];
|
||||||
|
/**
|
||||||
|
* The bots working there, to pick who has a new task.
|
||||||
|
*/
|
||||||
|
bots: { id?: number | string | null; name?: string | null }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every task in a workspace, whichever bot has it (or shared, nobody's
|
* Every task in a workspace, whichever bot has it: every task belongs to
|
||||||
* yet). Each bot there sees its own and the shared ones. Add and change one by hand here; the bots
|
* one bot, which sees only its own (the workspace's admin bot sees all).
|
||||||
|
* Add and change one by hand here; the bots
|
||||||
* keep it up to date themselves as they work.
|
* keep it up to date themselves as they work.
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
export default function WorkspaceTasksGroup({ workspace_id, tasks, bots }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const [items, setItems] = useState(tasks);
|
const [items, setItems] = useState(tasks);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
|
const [status, setStatus] = useState<{ error?: boolean; success?: boolean; msg: string }>();
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [open_task, setOpenTask] = useState<TaskDetail | null>(null);
|
const [open_task, setOpenTask] = useState<TaskWithNames | null>(null);
|
||||||
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
const can_manage = canManageCrew({ user_types: pageProps?.user_types });
|
||||||
|
|
||||||
const replace = ({ task }: { task: TaskWithNames }) =>
|
const replace = ({ task }: { task: TaskWithNames }) =>
|
||||||
@@ -62,101 +67,88 @@ export default function WorkspaceTasksGroup({ workspace_id, tasks }: Props) {
|
|||||||
replace({ task: res.singleRes });
|
replace({ task: res.singleRes });
|
||||||
};
|
};
|
||||||
|
|
||||||
const show = async ({ task }: { task: TaskWithNames }) => {
|
const remove = async ({ task }: { task: TaskWithNames }) => {
|
||||||
|
if (busy) return;
|
||||||
|
if (!window.confirm(`Delete "${task.title || `task ${task.id}`}"? Its notes and links go with it.`)) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
setStatus(undefined);
|
setStatus(undefined);
|
||||||
|
|
||||||
const res = await fetchApi<{}, APIResponseObject<TaskDetail>>(
|
const res = await fetchApi<Record<string, unknown>, APIResponseObject>(
|
||||||
`/api/admin/tasks/get-task?task_id=${task.id}`,
|
"/api/admin/tasks/delete-task",
|
||||||
{ method: "GET" },
|
{ method: "POST", body: { task_id: Number(task.id) } },
|
||||||
).catch(() => null);
|
);
|
||||||
|
|
||||||
if (!res?.success || !res.singleRes) {
|
setBusy(false);
|
||||||
setStatus({ error: true, msg: res?.msg || "Couldn't open the task." });
|
|
||||||
|
if (!res.success) {
|
||||||
|
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenTask(res.singleRes);
|
setItems((prev) => prev.filter((item) => item.id != task.id));
|
||||||
|
setStatus({ success: true, msg: "Task deleted." });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
id="tasks"
|
id="tasks"
|
||||||
title="Tasks"
|
title="Tasks"
|
||||||
description="Every task in this workspace, whichever bot has it. A bot sees its own here and the shared ones nobody has taken up yet."
|
description="Every task in this workspace, whichever bot has it. Each bot sees only its own; the workspace's admin bot and the coordinator see them all."
|
||||||
>
|
>
|
||||||
<Stack className="w-full gap-2! items-stretch">
|
<Stack className="w-full gap-2! items-stretch">
|
||||||
{open_task ? (
|
{can_manage ? (
|
||||||
<Stack className="w-full gap-3! items-stretch">
|
<Row className="justify-end">
|
||||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
<Button
|
||||||
<Span className="grow min-w-0 text-sm font-bold leading-5 text-ink truncate">
|
title={bots.length ? "Add a task" : "Add a bot to this workspace first"}
|
||||||
{open_task.title || "Task"}
|
type="button"
|
||||||
</Span>
|
variant="outlined"
|
||||||
<Button
|
color="gray"
|
||||||
title="Back to the list"
|
size="small"
|
||||||
type="button"
|
onClick={() => setAdding(true)}
|
||||||
variant="ghost"
|
disabled={!bots.length}
|
||||||
color="gray"
|
>
|
||||||
size="small"
|
Add task
|
||||||
onClick={() => setOpenTask(null)}
|
</Button>
|
||||||
className="px-2! text-[12px]"
|
</Row>
|
||||||
>
|
) : null}
|
||||||
Back
|
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
|
||||||
</Button>
|
{items.map((task) => (
|
||||||
</Row>
|
<TaskListItem
|
||||||
<TaskDetailPanel
|
key={task.id}
|
||||||
task={open_task}
|
task={task}
|
||||||
can_manage={can_manage}
|
busy={busy}
|
||||||
onChanged={(saved, msg) => {
|
onOpen={() => setOpenTask(task)}
|
||||||
setOpenTask(saved);
|
onStatusChange={(next) => move({ task, next })}
|
||||||
replace({ task: saved });
|
onDelete={() => remove({ task }).catch(() => {})}
|
||||||
setStatus(msg ? { success: true, msg } : undefined);
|
|
||||||
}}
|
|
||||||
onDeleted={() => {
|
|
||||||
setItems((prev) => prev.filter((item) => item.id != open_task.id));
|
|
||||||
setOpenTask(null);
|
|
||||||
setStatus({ success: true, msg: "Task deleted." });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
))}
|
||||||
) : (
|
</SettingsList>
|
||||||
<>
|
|
||||||
{can_manage ? (
|
|
||||||
<Row className="justify-end">
|
|
||||||
<Button
|
|
||||||
title="Add a task"
|
|
||||||
type="button"
|
|
||||||
variant="outlined"
|
|
||||||
color="gray"
|
|
||||||
size="small"
|
|
||||||
onClick={() => setAdding(true)}
|
|
||||||
>
|
|
||||||
Add task
|
|
||||||
</Button>
|
|
||||||
</Row>
|
|
||||||
) : null}
|
|
||||||
<SettingsList is_empty={!items.length} empty="No tasks in this workspace yet.">
|
|
||||||
{items.map((task) => (
|
|
||||||
<TaskListItem
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
busy={busy}
|
|
||||||
onOpen={() => show({ task }).catch(() => {})}
|
|
||||||
onStatusChange={(next) => move({ task, next })}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SettingsList>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<TaskDetailModal
|
||||||
|
task={open_task}
|
||||||
|
can_manage={can_manage}
|
||||||
|
onClose={() => setOpenTask(null)}
|
||||||
|
onChanged={({ task, msg }) => {
|
||||||
|
replace({ task });
|
||||||
|
setStatus(msg ? { success: true, msg } : undefined);
|
||||||
|
}}
|
||||||
|
onDeleted={({ task_id }) => {
|
||||||
|
setItems((prev) => prev.filter((item) => item.id != task_id));
|
||||||
|
setOpenTask(null);
|
||||||
|
setStatus({ success: true, msg: "Task deleted." });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<AddModalShell
|
<AddModalShell
|
||||||
open={adding}
|
open={adding}
|
||||||
setOpen={setAdding}
|
setOpen={setAdding}
|
||||||
title="Add a task"
|
title="Add a task"
|
||||||
description="It's added shared: whichever bot here takes it up has it. Hand it over to give it to one."
|
description="Pick the bot that has it. Hand it over later to give it to another."
|
||||||
>
|
>
|
||||||
<TaskForm
|
<TaskForm
|
||||||
workspace_id={workspace_id}
|
workspace_id={workspace_id}
|
||||||
|
bots={bots}
|
||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
onSaved={(task, msg) => {
|
onSaved={(task, msg) => {
|
||||||
replace({ task });
|
replace({ task });
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export default function WorkspaceDashboard() {
|
|||||||
<WorkspaceTasksGroup
|
<WorkspaceTasksGroup
|
||||||
workspace_id={workspace_id}
|
workspace_id={workspace_id}
|
||||||
tasks={pageProps?.workspace_tasks || []}
|
tasks={pageProps?.workspace_tasks || []}
|
||||||
|
bots={pageProps?.workspace_bots || []}
|
||||||
/>
|
/>
|
||||||
<WorkspaceActivityGroup
|
<WorkspaceActivityGroup
|
||||||
workspace_id={workspace_id}
|
workspace_id={workspace_id}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bune
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's
|
* The user's tasks: a bot's own list (`?bot_id=`), a workspace's
|
||||||
* (`?workspace_id=`), one bot's there with the unassigned ones
|
* (`?workspace_id=`), one bot's there (`?workspace_id=&bot_id=`), or every
|
||||||
* (`?workspace_id=&bot_id=`), or every task they reach. `?status=` narrows to one
|
* task they reach. `?status=` narrows to one
|
||||||
* status, `?open=1` to the open ones.
|
* status, `?open=1` to the open ones.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
|
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> = async ({ req }) => {
|
||||||
@@ -33,7 +33,6 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<TaskWithNames>> =
|
|||||||
payload: await grabTasks({
|
payload: await grabTasks({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
only_bot_id: bot_id,
|
only_bot_id: bot_id,
|
||||||
with_unassigned: Boolean(bot_id && workspace_id),
|
|
||||||
only_workspace_id: workspace_id,
|
only_workspace_id: workspace_id,
|
||||||
own_list: Boolean(bot_id && !workspace_id),
|
own_list: Boolean(bot_id && !workspace_id),
|
||||||
statuses: status ? [status] : undefined,
|
statuses: status ? [status] : undefined,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import safeErrorMessage from "@/src/utils/safe-error-message";
|
|||||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a task to a bot's list (`bot_id`), a workspace (`workspace_id`,
|
* Adds a task to a bot's list (`bot_id`) or a bot's in a workspace (with
|
||||||
* shared) or a bot in a workspace (both), or changes one (`task_id`). Returns the task with its
|
* `workspace_id`), or changes one (`task_id`). Returns the task with its
|
||||||
* notes and files.
|
* notes and files.
|
||||||
*/
|
*/
|
||||||
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
|
export const handler: BunextAPIRouteHandler<APIResponseObject<TaskDetail>> = async ({ req, body }) => {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ const status_values = TaskStatuses.map((status) => status.value) as [string, ...
|
|||||||
const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]];
|
const media_kind_values = TaskMediaKinds.map((kind) => kind.value) as [string, ...string[]];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where it goes: a bot's own list (`bot_id`), a workspace (`workspace_id`,
|
* Where it goes: a bot's own list (`bot_id`) or a bot's task in a
|
||||||
* shared), or a bot's task in a workspace (both). The helper refuses
|
* workspace (both). Every task has a bot: the helper refuses one without
|
||||||
* neither (`taskScopeValues`).
|
* (`taskScopeValues`).
|
||||||
*/
|
*/
|
||||||
const scope = {
|
const scope = {
|
||||||
bot_id: z.number().int().positive().optional(),
|
bot_id: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import fileIngest from "./file-ingest";
|
|||||||
import liveRelay from "./live-relay";
|
import liveRelay from "./live-relay";
|
||||||
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
|
||||||
import migrateBotTypes from "./functions/backend/db/bot-types/migrate-bot-types";
|
import migrateBotTypes from "./functions/backend/db/bot-types/migrate-bot-types";
|
||||||
|
import assignOwnerlessTasks from "./functions/backend/db/tasks/assign-ownerless-tasks";
|
||||||
import clearImplicitSessionLinks from "./functions/backend/db/bot-sessions/clear-implicit-session-links";
|
import clearImplicitSessionLinks from "./functions/backend/db/bot-sessions/clear-implicit-session-links";
|
||||||
import dropStoredFileText from "./functions/backend/db/stored-text/drop-stored-file-text";
|
import dropStoredFileText from "./functions/backend/db/stored-text/drop-stored-file-text";
|
||||||
import syncKnowledgebaseFts from "./functions/backend/db/knowledgebases/fts/sync-knowledgebase-fts";
|
import syncKnowledgebaseFts from "./functions/backend/db/knowledgebases/fts/sync-knowledgebase-fts";
|
||||||
@@ -38,6 +39,9 @@ await enableForeignKeys();
|
|||||||
await migrateBotTypes().catch((error) =>
|
await migrateBotTypes().catch((error) =>
|
||||||
logError({ title: "Could not migrate bot types", error }).catch(() => {}),
|
logError({ title: "Could not migrate bot types", error }).catch(() => {}),
|
||||||
);
|
);
|
||||||
|
await assignOwnerlessTasks().catch((error) =>
|
||||||
|
logError({ title: "Could not assign tasks without a bot", error }).catch(() => {}),
|
||||||
|
);
|
||||||
await clearImplicitSessionLinks().catch((error) =>
|
await clearImplicitSessionLinks().catch((error) =>
|
||||||
logError({ title: "Could not clear implicit session links", error }).catch(() => {}),
|
logError({ title: "Could not clear implicit session links", error }).catch(() => {}),
|
||||||
);
|
);
|
||||||
|
|||||||
+16
-3
@@ -236,11 +236,20 @@ export type TaskFile = BUN_SQLITE_COBBLE_TASK_MEDIA & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task with its notes and files: what the task modal and the workspace's
|
* A note on a task, with the name of the bot that wrote it.
|
||||||
* tasks group render.
|
*/
|
||||||
|
export type TaskNote = BUN_SQLITE_COBBLE_TASK_NOTES & {
|
||||||
|
bot_name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task with its notes and files and who added it: what the task modal
|
||||||
|
* renders.
|
||||||
*/
|
*/
|
||||||
export type TaskDetail = TaskWithNames & {
|
export type TaskDetail = TaskWithNames & {
|
||||||
notes: BUN_SQLITE_COBBLE_TASK_NOTES[];
|
created_by_bot_name?: string | null;
|
||||||
|
created_by_user_name?: string | null;
|
||||||
|
notes: TaskNote[];
|
||||||
files: TaskFile[];
|
files: TaskFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,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;
|
||||||
|
|||||||
@@ -19,11 +19,10 @@ type Params = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tasks a bot's run reaches: its own list, and in every workspace it
|
* The tasks a bot's run reaches. Every task belongs to one bot: a worker
|
||||||
* works in the tasks it has there plus the shared ones nobody has yet.
|
* reaches its own list and the tasks it has in the workspaces it still
|
||||||
* Another bot's task in a workspace is that bot's. A workspace admin
|
* works in, never another bot's. A workspace admin also reaches every task
|
||||||
* reaches every task in that workspace; the coordinator every task in the
|
* in that workspace; the coordinator every task in the install.
|
||||||
* install.
|
|
||||||
*/
|
*/
|
||||||
export default function botTaskScopesSql({
|
export default function botTaskScopesSql({
|
||||||
bot_id,
|
bot_id,
|
||||||
@@ -42,7 +41,7 @@ export default function botTaskScopesSql({
|
|||||||
sql: `(
|
sql: `(
|
||||||
(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
|
(${alias}.bot_id = ? AND ${alias}.workspace_id IS NULL)
|
||||||
OR (${alias}.workspace_id IS NOT NULL
|
OR (${alias}.workspace_id IS NOT NULL
|
||||||
AND (${alias}.bot_id IS NULL OR ${alias}.bot_id = ?)
|
AND ${alias}.bot_id = ?
|
||||||
AND ${alias}.workspace_id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?))
|
AND ${alias}.workspace_id IN (SELECT workspace_id FROM workspace_bots WHERE bot_id = ?))
|
||||||
${admin_sql}
|
${admin_sql}
|
||||||
)`,
|
)`,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ beforeAll(() => {
|
|||||||
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
|
db.run(`CREATE TABLE tasks (id INTEGER PRIMARY KEY, bot_id INTEGER, workspace_id INTEGER, created_by_bot_id INTEGER)`);
|
||||||
db.run(`INSERT INTO tasks VALUES
|
db.run(`INSERT INTO tasks VALUES
|
||||||
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
|
(100, 1, NULL, NULL), (101, 2, NULL, NULL), (102, 3, NULL, NULL),
|
||||||
(110, NULL, 10, NULL), (111, NULL, 11, NULL),
|
(110, 3, 10, NULL), (111, 1, 11, NULL),
|
||||||
(112, 3, 11, NULL), (113, 2, 11, NULL)`);
|
(112, 3, 11, NULL), (113, 2, 11, NULL)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,13 +54,13 @@ describe("taskScopesSql", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("botTaskScopesSql", () => {
|
describe("botTaskScopesSql", () => {
|
||||||
test("a worker reaches its own list, its tasks in its workspaces and the shared ones there", () => {
|
test("a worker reaches its own list and its tasks in its workspaces, never another bot's there", () => {
|
||||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 111, 112]);
|
expect(ids({ condition: botTaskScopesSql({ bot_id: 3, is_coordinator: false }) })).toEqual([102, 110, 112]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("another bot's task in a workspace stays that bot's", () => {
|
test("another bot's task in a workspace stays that bot's", () => {
|
||||||
db.run(`INSERT INTO workspace_bots VALUES (11, 2)`);
|
db.run(`INSERT INTO workspace_bots VALUES (11, 2)`);
|
||||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 111, 113]);
|
expect(ids({ condition: botTaskScopesSql({ bot_id: 2, is_coordinator: false }) })).toEqual([101, 113]);
|
||||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 2`);
|
db.run(`DELETE FROM workspace_bots WHERE bot_id = 2`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ describe("botTaskScopesSql for a workspace admin", () => {
|
|||||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
|
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false, admin_workspace_ids: [11] }) })).toEqual([
|
||||||
111, 112, 113,
|
111, 112, 113,
|
||||||
]);
|
]);
|
||||||
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([111]);
|
expect(ids({ condition: botTaskScopesSql({ bot_id: 4, is_coordinator: false }) })).toEqual([]);
|
||||||
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
|
db.run(`DELETE FROM workspace_bots WHERE bot_id = 4`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ export type SqlCondition = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a task is: a bot's own list (`bot_id` alone), or a workspace
|
* Where a task is: a bot's own list (`bot_id` alone), or a workspace
|
||||||
* (`workspace_id`, with the bot that has it, if any).
|
* (`workspace_id`, with the `bot_id` that has it there). Every task has a
|
||||||
|
* bot.
|
||||||
*/
|
*/
|
||||||
export type TaskScope = {
|
export type TaskScope = {
|
||||||
bot_id?: number | null;
|
bot_id?: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user