dopus 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # dopus
2
2
 
3
- Drop an AI agent into your existing Next.js app. Your server functions become its tools.
3
+ A lightweight agent for Next.js. Hand it the server functions your app already has, and it does the user's tasks with
4
+ them, with the user's own permissions.
4
5
 
5
6
  ```bash
6
7
  pnpm add dopus
@@ -14,6 +15,21 @@ pnpm dopus init
14
15
 
15
16
  Built on the [Vercel AI SDK](https://ai-sdk.dev) 7. Supports OpenAI, Anthropic and Google models.
16
17
 
18
+ **What it is for.** An assistant inside your app that answers questions about the user's data and makes small changes
19
+ on request: "show my open orders", "move this task to done", "assign the deploy checklist to Carol". Each turn is a few
20
+ quick server-function calls inside one request, usually a few seconds.
21
+
22
+ **What it is not.** dopus stays small on purpose. These are out of scope:
23
+
24
+ - Running long work itself. A tool should finish in seconds. Longer jobs run in your own queue, Lambda or worker; a
25
+ tool starts them, and the agent picks up again when your app announces they finished (see
26
+ [Background jobs](#background-jobs)).
27
+ - Autonomous, scheduled or multi-agent workflows.
28
+ - Built-in memory, RAG or a vector store. If you need search over documents, expose it as a tool.
29
+ - Frameworks other than Next.js, and the Pages Router.
30
+ - Running the agent somewhere other than your Next.js app. `dopus infra` only writes the deploy config that keeps the
31
+ agent route streaming on AWS, Cloudflare or Cloud Run.
32
+
17
33
  ---
18
34
 
19
35
  ## Quick start
@@ -67,6 +83,27 @@ dopus init [--provider openai|anthropic|google] [--model <id>]
67
83
 
68
84
  `--yes` runs without prompts. It needs `--provider` and skips the API key prompt, so add the key to `.env.local` yourself afterwards.
69
85
 
86
+ ### Updating
87
+
88
+ `init` copies files into your project, so a new dopus version does not change them by itself. After upgrading the
89
+ package, bring them up to date:
90
+
91
+ ```bash
92
+ pnpm add dopus@latest
93
+ pnpm dopus update # --dry-run to only see what would change
94
+ ```
95
+
96
+ `update` handles the files `init` copied: the chat UI (`agent-chat.tsx`, `agent-chat.css`), the route and the preset
97
+ prompt. It never touches `agent.config.ts`, `agent/prompts/app.ts` or `.env.local`.
98
+
99
+ - A file you never edited (it matches a template some dopus version shipped) is replaced.
100
+ - A file you edited is never replaced silently. You choose: see the diff, keep yours and get the new template next to
101
+ it as `<file>.new` to merge by hand, replace it, or skip. `--yes` always takes the `.new` route; `--force` replaces.
102
+ - Packages the new chat UI needs are installed (`--no-install` prints the command instead).
103
+
104
+ Commit first, so `git diff` shows exactly what changed. The [changelog](CHANGELOG.md) says what each version changed
105
+ and anything to do in `agent.config.ts`.
106
+
70
107
  ## Tools
71
108
 
72
109
  ```ts
@@ -108,6 +145,20 @@ export default defineAgent({
108
145
 
109
146
  `ctx` is `{ user, toolCallId, abortSignal }`. `user` is only set when you configure `identify` (see below).
110
147
 
148
+ ### Designing tools
149
+
150
+ A lightweight agent works best with small, fast tools:
151
+
152
+ - **Few.** Register the tools a user needs here, not every function you have. Past roughly 20 to 30 tools, models pick
153
+ the wrong one more often, and every request carries every description.
154
+ - **Fast.** A tool should return in a second or two. Longer work belongs in a job (see
155
+ [Timeouts and long-running tools](#timeouts-and-long-running-tools)).
156
+ - **Small results.** Take filters and a limit, and return only the fields the agent needs (`output` helps). A thousand
157
+ rows cost tokens on every later step, and the whole history counts against `limits.maxBodyBytes`.
158
+ - **Batch, don't loop.** "Close all 200 stale tickets" should be one server function that takes a filter, not 200 calls.
159
+ A turn allows at most `maxSteps` (10) model ↔ tool round trips.
160
+ - **Clear names and descriptions.** The model chooses by them. Say when _not_ to use a tool in `usage`.
161
+
111
162
  ### Errors
112
163
 
113
164
  Only two kinds of error message reach the model:
@@ -151,7 +202,7 @@ Your part, so an injected instruction cannot cause damage:
151
202
 
152
203
  We tested this with a real model in a small task-board app. A member wrote a task whose title ordered the assistant to delete other tasks, then an admin's assistant listed the tasks. Claude (claude-sonnet-5-5) did not even request the deletion and pointed out the planted text. If a model did request it, the approval card would still stop it. Other models were not tested this way.
153
204
 
154
- **Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. Infra adapters that run tools elsewhere will build on this.
205
+ **Running tools outside the request.** Background jobs and queues lose the request's cookies. For that case, define `identify(request)` to resolve a serialisable user. Tools then receive it as `ctx.user` and can pass it on explicitly. dopus itself always runs tools inside the request; this is for jobs your tools hand off.
155
206
 
156
207
  ## Prompts
157
208
 
@@ -173,9 +224,12 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
173
224
  - `<AgentChat />`: the panel;
174
225
  - `<AgentWidget />`: a floating launcher that opens the panel.
175
226
 
176
- Both accept `api`, `suggestions`, `labels` (every UI string, for translation) and `className`.
227
+ Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
228
+ `maxJobFollowUps` for [background jobs](#background-jobs).
177
229
 
178
- The UI shows each tool call as it runs. It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
230
+ The UI shows each tool call as it runs. When a reply finishes while the widget is closed (for example the agent
231
+ continuing after a [background job](#background-jobs)), the launcher shows a dot until it is opened (`labels.unread`
232
+ for screen readers; `onReply` if you want your own signal). It renders an approve/decline card for confirmed tools and continues automatically once the user answers. When a tool has run, it calls `router.refresh()` so server components show the changed data. Closing the widget keeps the conversation, and a "New chat" button clears it. Assistant replies render as Markdown (tables, lists, code) through `react-markdown` with `remark-gfm`, which `init` installs. Raw HTML is never rendered, images show only their alt text, and links keep only http(s), mailto and relative URLs. If a request is over the size limit (413), the UI says so and offers a new chat instead of a useless retry. It also has stop and retry buttons, and it does not send on Enter while an IME (Korean, Japanese, Chinese) is still composing.
179
233
 
180
234
  Styling is one plain CSS file (`agent-chat.css`) whose classes all start with `dopus-`, themed through `--dopus-*` variables, with dark mode included. It is deliberately not a CSS Module: create-next-app's Tailwind setup routes every `*.css` file, modules included, through its own Turbopack loader, which left module classes unstyled. It needs no Tailwind and no component library. The file is yours, so restyle it or swap in your own components.
181
235
 
@@ -288,11 +342,98 @@ The job runs in your queue or background worker, outside the request, so it does
288
342
  permissions in `startExportJob` and pass the user to the job explicitly (see `identify` and `ctx.user`). Have `getExportJob`
289
343
  check that the job belongs to the caller.
290
344
 
345
+ With a status tool alone, the user has to ask "is it done?". To have the agent continue by itself when the job finishes,
346
+ mark the start tool with `job` and give the chat UI a channel: see [Background jobs](#background-jobs).
347
+
291
348
  **If a turn is cut anyway**, tools that already ran stay done. "Retry" asks the model again and may call a tool again.
292
349
  Tools behind `confirm: true` need a new approval; make other writes idempotent.
293
350
 
351
+ ## Background jobs
352
+
353
+ For work that takes longer than a request: a Lambda that calls an LLM for a minute, a report, a bulk import. The tool
354
+ starts the job and returns at once; your worker does the work; your app announces "job X finished" on a channel it
355
+ already has (Supabase Realtime, a WebSocket, SSE, polling). The chat UI hears it and the agent continues: it reads the
356
+ result with another of your tools and does what the user asked next.
357
+
358
+ ```
359
+ user: "build the workload report, then assign unassigned tasks to whoever is least busy"
360
+ → startWorkloadReport returns { jobId } at once; the agent says it started, the turn ends
361
+ … your worker runs for a minute, saves the result, publishes "job-7 finished" on your channel …
362
+ → the chat UI hears job-7 sends a notice carrying only the ID; a new turn starts
363
+ → getReportResult({ jobId }) your tool reads the result and checks it belongs to the user
364
+ → assignTask(...) confirmed tools still stop at the approval card
365
+ ```
366
+
367
+ **Server**: mark the tool that starts the job, and add a tool that reads the result.
368
+
369
+ ```ts
370
+ startReport: defineTool(() => startReportJob(), { // invoke the Lambda / enqueue, return { jobId } at once
371
+ description: 'Starts the workload report. Takes about a minute.',
372
+ input: z.object({}),
373
+ job: (result) => result.jobId, // this tool starts a background job
374
+ }),
375
+ getReportResult: defineTool((input) => readReport(input.jobId), { // check the job belongs to the caller
376
+ description: 'Reads a report job by its id: status and, when done, the report.',
377
+ usage: 'After the app says a report job has finished.',
378
+ input: z.object({ jobId: z.string() }),
379
+ }),
380
+ ```
381
+
382
+ `job` returns the job's ID (letters, digits, `_ . : -`, up to 128 characters). dopus tells the model the job runs in
383
+ the background and that it will be told when it finishes, so it does not poll.
384
+
385
+ **Client**: give the chat a `channel`. It holds functions, so create it in a client component, once:
386
+
387
+ ```tsx
388
+ // components/agent/agent.tsx
389
+ 'use client'
390
+ import { type AgentChannel, type AgentChatProps, AgentWidget } from './agent-chat'
391
+ import { supabase } from '@/lib/supabase/client'
392
+
393
+ const channel: AgentChannel = {
394
+ subscribe(finished) {
395
+ const ch = supabase
396
+ .channel('jobs', { config: { private: true } })
397
+ .on('broadcast', { event: 'job-done' }, ({ payload }) => finished(payload.jobId))
398
+ .subscribe()
399
+ return () => {
400
+ supabase.removeChannel(ch)
401
+ }
402
+ },
403
+ }
404
+
405
+ export function Agent(props: AgentChatProps) {
406
+ return <AgentWidget {...props} channel={channel} />
407
+ }
408
+ ```
409
+
410
+ Any source works: `new EventSource(url)`, a WebSocket, or a `setInterval` that asks your API which jobs finished. Call
411
+ `finished(jobId)` for each one; return a function that stops listening. Then use `<Agent />` in your layout instead of
412
+ `<AgentWidget />`.
413
+
414
+ **How it is kept safe:**
415
+
416
+ - **The channel is a doorbell, not a mailbox.** Only the job ID goes from the channel to the server. The text the model
417
+ sees ("Background job X has finished…") is written by the server, and the result comes from your tool, which checks
418
+ permissions. A forged or replayed event can at most make the agent look up a job the user can already read.
419
+ - **Only this chat's jobs.** The UI reacts only to IDs that a tool in this conversation returned and that it has not
420
+ reported yet. Another tab, or a new chat, ignores them.
421
+ - **Follow-ups are capped.** After `maxJobFollowUps` (default 3) automatic turns in a row, a finished job is shown in
422
+ the chat but the agent waits for the user's next message, so a job that starts another job cannot loop.
423
+ - **Approvals still apply.** An automatic turn stops at the approval card like any other.
424
+
425
+ **Limits:**
426
+
427
+ - The follow-up runs only while the chat is open in a browser. If the user closes the tab, the result is still in your
428
+ app (show it there); the agent just does not act on it. Work that must continue without the user belongs in your
429
+ worker, not in the agent.
430
+ - Each finished job costs one more model call.
431
+
294
432
  ## Deploying
295
433
 
434
+ The `dopus infra` commands write the deploy configuration that keeps the agent route streaming within each platform's
435
+ limits. The agent still runs inside your Next.js app; nothing is moved elsewhere.
436
+
296
437
  **Vercel / any Node server.** Nothing extra. The route streams. Raise `maxDuration` in `app/api/agent/route.ts` if your plan allows it; the time budget follows it.
297
438
 
298
439
  **AWS (OpenNext / SST).**
@@ -370,9 +511,8 @@ Swap in a scripted `MockLanguageModelV4` from `ai/test` behind an environment va
370
511
 
371
512
  dopus is pre-1.0, so expect breaking changes before 1.0. It is tested on Next.js 16 with the Node.js runtime.
372
513
 
373
- Planned next:
514
+ Planned next, within the lightweight scope above:
374
515
 
375
- - `dopus prompts diff` to compare your copies with newer templates;
376
516
  - tool input schemas inferred from TypeScript types.
377
517
 
378
518
  ## Development
@@ -382,7 +522,8 @@ pnpm install
382
522
  pnpm build && pnpm lint && pnpm typecheck && pnpm test
383
523
  pnpm example:build && pnpm example:e2e # next start + scripted model
384
524
  pnpm example:live # real model from examples/next-app/.env.local (costs cents)
385
- pnpm smoke # packed tarball → fresh app → dopus init → next build
525
+ pnpm smoke # packed tarball → fresh app → init (older release) → update → next build
526
+ pnpm templates:history # after changing a template: record its hash for `dopus update`
386
527
  pnpm aws:emu # OpenNext build → local Lambda streaming invoke
387
528
  pnpm cf:e2e # dopus infra cloudflare → wrangler dev (workerd) → e2e
388
529
  pnpm gcp:e2e # dopus infra gcp → docker build/run (Cloud Run style) → e2e
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { n as PROVIDERS, t as VERSION } from "./version-DmrGrSmM.js";
2
+ import { n as PROVIDERS, t as VERSION } from "./version-DqzDElrt.js";
3
3
  import { execFileSync, spawn } from "node:child_process";
4
4
  import { parseArgs } from "node:util";
5
5
  import * as p from "@clack/prompts";
6
6
  import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join, relative, resolve } from "node:path";
8
- import { randomBytes } from "node:crypto";
8
+ import { createHash, randomBytes } from "node:crypto";
9
9
  import { fileURLToPath } from "node:url";
10
10
  // src/init/detect.ts
11
11
  var InitError = class extends Error {
@@ -808,11 +808,249 @@ async function runInfraGcp(options, io) {
808
808
  ].join("\n"), "Cloud Run notes");
809
809
  return result;
810
810
  }
811
+ // src/update/diff.ts
812
+ /** Line diff (LCS) in unified style with 3 lines of context. Small and dependency-free; templates are short. */
813
+ function unifiedDiff(before, after, context = 3) {
814
+ const a = before.replace(/\r\n/g, "\n").split("\n");
815
+ const b = after.replace(/\r\n/g, "\n").split("\n");
816
+ const n = a.length;
817
+ const m = b.length;
818
+ if (n * m > 4e6) return "(files too large to diff here)";
819
+ const table = new Uint32Array((n + 1) * (m + 1));
820
+ const lcs = (i, j) => table[i * (m + 1) + j] ?? 0;
821
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) table[i * (m + 1) + j] = a[i] === b[j] ? lcs(i + 1, j + 1) + 1 : Math.max(lcs(i + 1, j), lcs(i, j + 1));
822
+ const lines = [];
823
+ let i = 0;
824
+ let j = 0;
825
+ while (i < n || j < m) if (i < n && j < m && a[i] === b[j]) {
826
+ lines.push({
827
+ op: " ",
828
+ text: a[i] ?? ""
829
+ });
830
+ i++;
831
+ j++;
832
+ } else if (i < n && (j >= m || lcs(i + 1, j) >= lcs(i, j + 1))) {
833
+ lines.push({
834
+ op: "-",
835
+ text: a[i] ?? ""
836
+ });
837
+ i++;
838
+ } else {
839
+ lines.push({
840
+ op: "+",
841
+ text: b[j] ?? ""
842
+ });
843
+ j++;
844
+ }
845
+ const changed = [];
846
+ lines.forEach((line, index) => {
847
+ if (line.op !== " ") changed.push(index);
848
+ });
849
+ const out = [];
850
+ let start = 0;
851
+ while (start < changed.length) {
852
+ let end = start;
853
+ while (end + 1 < changed.length && (changed[end + 1] ?? 0) - (changed[end] ?? 0) <= context * 2) end++;
854
+ const from = Math.max(0, (changed[start] ?? 0) - context);
855
+ const to = Math.min(lines.length - 1, (changed[end] ?? 0) + context);
856
+ out.push("@@");
857
+ for (const line of lines.slice(from, to + 1)) out.push(`${line.op}${line.text}`);
858
+ start = end + 1;
859
+ }
860
+ return out.join("\n");
861
+ }
862
+ // src/update/history.ts
863
+ /** The generated route imports the agent config by a path that differs per project. */
864
+ const CONFIG_IMPORT = /^import agent from ['"]([^'"]+)['"]/m;
865
+ /** Must match normalize() in scripts/template-history.mjs. */
866
+ function normalize(text) {
867
+ return text.replace(/\r\n/g, "\n").replace(CONFIG_IMPORT, "import agent from '<config>'");
868
+ }
869
+ function templateHash(text) {
870
+ return createHash("sha256").update(normalize(text)).digest("hex").slice(0, 16);
871
+ }
872
+ let cached;
873
+ function templateHistory() {
874
+ cached ??= JSON.parse(readTemplate("history.json"));
875
+ return cached;
876
+ }
877
+ /** The dopus version whose untouched template this is, or undefined if the user changed it. */
878
+ function shippedIn(key, text) {
879
+ return templateHistory()[key]?.[templateHash(text)];
880
+ }
881
+ // src/update/run.ts
882
+ /** Packages the chat UI imports (the rest were added by `init` and are needed by the route too). */
883
+ const UI_DEPENDENCIES = [
884
+ ["@ai-sdk/react", "@ai-sdk/react@^4"],
885
+ ["react-markdown", "react-markdown@^10"],
886
+ ["remark-gfm", "remark-gfm@^4"]
887
+ ];
888
+ /** Which preset an existing preset.ts came from: by its hash, else the one it shares most lines with. */
889
+ function presetKey(text) {
890
+ const keys = PRESETS.map((preset) => `prompts/presets/${preset.id}.ts`);
891
+ const exact = keys.find((key) => shippedIn(key, text));
892
+ if (exact) return exact;
893
+ const lines = new Set(text.split("\n").map((line) => line.trim()));
894
+ const shared = (key) => readTemplate(key).split("\n").filter((line) => line.trim() && lines.has(line.trim())).length;
895
+ return keys.reduce((best, key) => shared(key) > shared(best) ? key : best);
896
+ }
897
+ /**
898
+ * Brings the files `dopus init` copied (chat UI, route, preset prompt) up to this version's templates.
899
+ * Untouched copies of an older version are replaced; copies the user edited are never replaced without
900
+ * asking. agent.config.ts, app.ts and .env.local are the user's and are never touched.
901
+ */
902
+ async function runUpdate(options, io) {
903
+ const project = detectProject(options.cwd);
904
+ const { root } = project;
905
+ const at = (path) => join(project.configDir, path);
906
+ const read = (file) => readFileSync(join(root, file), "utf8");
907
+ const exists = (file) => existsSync(join(root, file));
908
+ const result = {
909
+ files: [],
910
+ installed: [],
911
+ missingDependencies: []
912
+ };
913
+ const chatFile = at("components/agent/agent-chat.tsx");
914
+ const routeFile = join(project.appDir, "api", "agent", "route.ts");
915
+ const presetFile = at("agent/prompts/preset.ts");
916
+ const targets = [{
917
+ file: chatFile,
918
+ key: "ui/agent-chat.tsx",
919
+ latest: readTemplate("ui/agent-chat.tsx")
920
+ }, {
921
+ file: at("components/agent/agent-chat.css"),
922
+ key: "ui/agent-chat.css",
923
+ latest: readTemplate("ui/agent-chat.css"),
924
+ create: exists(chatFile)
925
+ }];
926
+ if (exists(routeFile)) {
927
+ const configImport = read(routeFile).match(CONFIG_IMPORT)?.[1] ?? toImportPath(routeFile, at("agent.config"));
928
+ targets.push({
929
+ file: routeFile,
930
+ key: "route",
931
+ latest: routeTemplate(configImport)
932
+ });
933
+ } else result.files.push({
934
+ file: routeFile,
935
+ status: "missing"
936
+ });
937
+ if (exists(presetFile)) {
938
+ const key = presetKey(read(presetFile));
939
+ targets.push({
940
+ file: presetFile,
941
+ key,
942
+ latest: readTemplate(key)
943
+ });
944
+ } else result.files.push({
945
+ file: presetFile,
946
+ status: "missing"
947
+ });
948
+ const write = (file, content) => {
949
+ if (!options.dryRun) writeFileSync(join(root, file), content);
950
+ };
951
+ for (const target of targets) {
952
+ const { file, latest } = target;
953
+ if (!exists(file)) {
954
+ if (target.create) {
955
+ write(file, latest);
956
+ result.files.push({
957
+ file,
958
+ status: "created"
959
+ });
960
+ } else result.files.push({
961
+ file,
962
+ status: "missing"
963
+ });
964
+ continue;
965
+ }
966
+ const current = read(file);
967
+ if (normalize(current) === normalize(latest)) {
968
+ result.files.push({
969
+ file,
970
+ status: "up-to-date"
971
+ });
972
+ continue;
973
+ }
974
+ const from = shippedIn(target.key, current);
975
+ if (from) {
976
+ write(file, latest);
977
+ result.files.push({
978
+ file,
979
+ status: "updated",
980
+ from
981
+ });
982
+ continue;
983
+ }
984
+ let choice;
985
+ if (options.dryRun) {
986
+ result.files.push({
987
+ file,
988
+ status: "modified"
989
+ });
990
+ continue;
991
+ } else if (options.force) choice = "overwrite";
992
+ else if (options.yes) choice = "new";
993
+ else do {
994
+ choice = await io.choose(`${file} has your changes. Replace it with the new template?`, [
995
+ {
996
+ value: "diff",
997
+ label: "Show the diff (yours → new)"
998
+ },
999
+ {
1000
+ value: "new",
1001
+ label: `Keep yours, save the new one as ${file}.new`
1002
+ },
1003
+ {
1004
+ value: "overwrite",
1005
+ label: "Replace (your changes are lost)"
1006
+ },
1007
+ {
1008
+ value: "skip",
1009
+ label: "Keep yours, skip"
1010
+ }
1011
+ ]);
1012
+ if (choice === "diff") io.diff(unifiedDiff(current, latest));
1013
+ } while (choice === "diff");
1014
+ if (choice === "overwrite") {
1015
+ write(file, latest);
1016
+ result.files.push({
1017
+ file,
1018
+ status: "overwritten"
1019
+ });
1020
+ } else if (choice === "new") {
1021
+ write(`${file}.new`, latest);
1022
+ result.files.push({
1023
+ file,
1024
+ status: "saved-new"
1025
+ });
1026
+ } else result.files.push({
1027
+ file,
1028
+ status: "kept"
1029
+ });
1030
+ }
1031
+ const chatReplaced = result.files.some((f) => f.file === chatFile && (f.status === "updated" || f.status === "overwritten"));
1032
+ const oldModule = at("components/agent/agent-chat.module.css");
1033
+ if (chatReplaced && exists(oldModule)) io.warn(`${oldModule} is no longer used by the chat UI (it now imports agent-chat.css). Delete it.`);
1034
+ if (exists(chatFile)) {
1035
+ const missing = UI_DEPENDENCIES.filter(([name]) => !project.dependencies[name]).map(([, spec]) => spec);
1036
+ if (missing.length > 0) {
1037
+ if (!options.dryRun && options.install !== false && (options.yes || options.force || await io.confirm(`Install ${missing.join(", ")}?`, true))) {
1038
+ await io.install(project.packageManager, missing, root);
1039
+ result.installed = missing;
1040
+ } else {
1041
+ result.missingDependencies = missing;
1042
+ io.warn(`The chat UI needs: ${project.packageManager} ${installArgs(project.packageManager, missing).join(" ")}`);
1043
+ }
1044
+ }
1045
+ }
1046
+ return result;
1047
+ }
811
1048
  // src/cli.ts
812
1049
  const HELP = `dopus ${VERSION}
813
1050
 
814
1051
  Usage:
815
1052
  dopus init [options] Add an AI agent to this Next.js app
1053
+ dopus update [options] Update the chat UI, route and preset prompt that init copied
816
1054
  dopus infra aws [options] Prepare the agent route for AWS Lambda (OpenNext streaming + SST snippet)
817
1055
  dopus infra cloudflare Prepare the app for Cloudflare Workers (@opennextjs/cloudflare)
818
1056
  dopus infra gcp Prepare the app for Cloud Run (standalone build + Dockerfile)
@@ -826,6 +1064,13 @@ Options for init:
826
1064
  --force Overwrite generated files that already exist
827
1065
  --cwd <dir> Project root (default: current directory)
828
1066
 
1067
+ Options for update (first install the new version: pnpm add dopus@latest):
1068
+ --dry-run Show what would change; write nothing
1069
+ -y, --yes Do not ask: files you edited get the new template next to them as <file>.new
1070
+ --force Replace files you edited too
1071
+ --no-install Do not install dependencies
1072
+ --cwd <dir> Project root
1073
+
829
1074
  Options for infra:
830
1075
  -y, --yes Do not ask before installing @opennextjs/aws
831
1076
  --no-install Print the install command instead
@@ -928,6 +1173,62 @@ async function init(args) {
928
1173
  p.note(steps.map((s, i) => `${i + 1}. ${s}`).join("\n"), "Next steps");
929
1174
  p.outro("Done.");
930
1175
  }
1176
+ const STATUS = {
1177
+ "up-to-date": "up to date",
1178
+ updated: "updated",
1179
+ created: "created",
1180
+ modified: "has your changes: would ask (run without --dry-run)",
1181
+ overwritten: "replaced (your changes are gone)",
1182
+ kept: "kept your version",
1183
+ "saved-new": "kept your version; new template saved as .new next to it",
1184
+ missing: "not found (not created by dopus init?)"
1185
+ };
1186
+ function colorDiff(text) {
1187
+ if (!process.stdout.isTTY) return text;
1188
+ return text.split("\n").map((line) => line.startsWith("+") ? `\x1b[32m${line}\x1b[0m` : line.startsWith("-") ? `\x1b[31m${line}\x1b[0m` : line.startsWith("@@") ? `\x1b[36m${line}\x1b[0m` : line).join("\n");
1189
+ }
1190
+ async function update(args) {
1191
+ const { values } = parseArgs({
1192
+ args,
1193
+ options: {
1194
+ "dry-run": { type: "boolean" },
1195
+ yes: {
1196
+ type: "boolean",
1197
+ short: "y"
1198
+ },
1199
+ force: { type: "boolean" },
1200
+ install: {
1201
+ type: "boolean",
1202
+ default: true
1203
+ },
1204
+ "no-install": { type: "boolean" },
1205
+ cwd: { type: "string" }
1206
+ },
1207
+ allowNegative: true
1208
+ });
1209
+ p.intro(`dopus ${VERSION} · update${values["dry-run"] ? " (dry run)" : ""}`);
1210
+ p.log.info(`Templates come from the installed dopus (${VERSION}). For the newest, run \`pnpm add dopus@latest\` first.`);
1211
+ const updateIO = {
1212
+ ...io,
1213
+ async choose(message, options) {
1214
+ return cancelled(await p.select({
1215
+ message,
1216
+ options
1217
+ }));
1218
+ },
1219
+ diff: (text) => console.log(colorDiff(text || "(no differences)"))
1220
+ };
1221
+ const result = await runUpdate({
1222
+ cwd: values.cwd ?? process.cwd(),
1223
+ dryRun: values["dry-run"],
1224
+ yes: values.yes,
1225
+ force: values.force,
1226
+ install: values["no-install"] ? false : values.install
1227
+ }, updateIO);
1228
+ p.note(result.files.map(({ file, status, from }) => `${file}: ${STATUS[status]}${from ? ` (was ${from})` : ""}`).join("\n"), "Files");
1229
+ if (result.files.some((f) => f.status === "saved-new")) p.log.info("Merge each .new file into yours, then delete it.");
1230
+ p.outro("What changed: https://github.com/akodm/dopus/blob/main/CHANGELOG.md");
1231
+ }
931
1232
  async function infra(args) {
932
1233
  const [target, ...rest] = args;
933
1234
  const runners = {
@@ -970,6 +1271,7 @@ async function main() {
970
1271
  if (rest.includes("--help") || rest.includes("-h")) return console.log(HELP);
971
1272
  if (command === "init") return init(rest);
972
1273
  if (command === "infra") return infra(rest);
1274
+ if (command === "update") return update(rest);
973
1275
  console.log(HELP);
974
1276
  if (command && command !== "--help" && command !== "-h") process.exitCode = 1;
975
1277
  }
package/dist/index.d.ts CHANGED
@@ -60,6 +60,12 @@ interface ToolOptions<SCHEMA extends z.ZodType, RESULT> {
60
60
  confirm?: ConfirmOption<z.infer<SCHEMA>>;
61
61
  /** Map the function's return value before it is sent to the model (trim fields, hide secrets). */
62
62
  output?: (result: Awaited<RESULT>) => unknown;
63
+ /**
64
+ * Marks a tool that starts a background job and returns at once (hands work to a queue, a Lambda, ...).
65
+ * Return the job's ID from the function's result. The chat UI waits for that ID on its `channel`; when
66
+ * the job finishes the agent is told, reads the result with another of your tools, and continues.
67
+ */
68
+ job?: (result: Awaited<RESULT>) => string;
63
69
  }
64
70
  type ToolFunction<INPUT, RESULT> = (input: INPUT, ctx: ToolContext) => RESULT;
65
71
  interface DopusTool<SCHEMA extends z.ZodType = z.ZodType, RESULT = unknown> {
@@ -210,5 +216,5 @@ interface ProviderInfo {
210
216
  }
211
217
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
212
218
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
213
- export declare const VERSION = "0.2.5";
219
+ export declare const VERSION = "0.2.7";
214
220
  export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
package/dist/index.js CHANGED
@@ -1,5 +1,32 @@
1
- import { n as PROVIDERS, t as VERSION } from "./version-DmrGrSmM.js";
1
+ import { n as PROVIDERS, t as VERSION } from "./version-DqzDElrt.js";
2
2
  import { convertToModelMessages, createUIMessageStreamResponse, getToolName, isStepCount, isToolUIPart, safeValidateUIMessages, streamText, toUIMessageStream, tool } from "ai";
3
+ // src/job.ts
4
+ /**
5
+ * Background jobs: a tool marked with `job` starts work elsewhere (a queue, a Lambda) and returns at once.
6
+ * The chat UI listens on the app's channel for "job <id> finished", then sends a notice part back. The
7
+ * notice carries only the job ID; the agent reads the result with one of the app's tools, which checks the
8
+ * user's permissions. Nothing the channel delivers reaches the model as text.
9
+ */
10
+ /** Key added to a job tool's output; the chat UI reads it to know which jobs this conversation started. */
11
+ const JOB_OUTPUT_KEY = "dopusJob";
12
+ const JOB_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
13
+ function isJobId(value) {
14
+ return typeof value === "string" && JOB_ID.test(value);
15
+ }
16
+ const JOB_USAGE = "This starts a background job and returns at once. The app tells you in this chat when the job finishes; do not check on it unless the user asks.";
17
+ function withJob(output, id) {
18
+ return typeof output === "object" && output !== null && !Array.isArray(output) ? {
19
+ ...output,
20
+ [JOB_OUTPUT_KEY]: id
21
+ } : {
22
+ result: output,
23
+ [JOB_OUTPUT_KEY]: id
24
+ };
25
+ }
26
+ /** Server-written text for a job notice. Only the validated ID comes from the client. */
27
+ function jobNotice(id) {
28
+ return `[Background job ${id} has finished. This notice comes from the app, not from the user. Read the job's result with your tools, then continue with what the user asked, and tell the user briefly what came of it.]`;
29
+ }
3
30
  // src/next-errors.ts
4
31
  /**
5
32
  * An error whose message is safe to show to the model (and, through it, the user).
@@ -110,7 +137,8 @@ function isDopusTool(value) {
110
137
  function toolDescription(options) {
111
138
  const description = options.description.trim();
112
139
  const usage = options.usage?.trim();
113
- return usage ? `${description}\n\nWhen to use: ${usage}` : description;
140
+ const text = usage ? `${description}\n\nWhen to use: ${usage}` : description;
141
+ return options.job ? `${text}\n\n${JOB_USAGE}` : text;
114
142
  }
115
143
  // src/agent.ts
116
144
  const KEEP_ALIVE = new TextEncoder().encode(": keep-alive\n\n");
@@ -198,7 +226,13 @@ function buildTools(tools, ctx) {
198
226
  toolCallId,
199
227
  abortSignal
200
228
  });
201
- const output = options.output ? options.output(result) : result;
229
+ const mapped = options.output ? options.output(result) : result;
230
+ let output = mapped;
231
+ if (options.job) {
232
+ const jobId = options.job(result);
233
+ if (!isJobId(jobId)) throw new TypeError(`tools.${name}: job() must return an ID matching [A-Za-z0-9_.:-]{1,128}`);
234
+ output = withJob(mapped, jobId);
235
+ }
202
236
  await runHook(ctx.onToolCall, {
203
237
  ...base,
204
238
  outcome: "success",
@@ -284,6 +318,7 @@ function createAgentHandler(config, options = {}) {
284
318
  if (!validated.success) return errorResponse(400, "Invalid messages");
285
319
  const messages = validated.data;
286
320
  if (messages.some((message) => message.role === "user" && message.parts.some((part) => part.type === "text" && part.text.length > maxMessageChars))) return errorResponse(413, "Message is too long");
321
+ if (messages.some((message) => message.parts.some((part) => part.type === "data-dopus-job" && !isJobId(part.data?.id)))) return errorResponse(400, "Invalid job notice");
287
322
  const early = await config.beforeRequest?.({
288
323
  request,
289
324
  user,
@@ -313,7 +348,13 @@ function createAgentHandler(config, options = {}) {
313
348
  ...config.callOptions,
314
349
  model: config.model,
315
350
  instructions: buildInstructions(config.prompt, runtime),
316
- messages: await convertToModelMessages(messages, { tools }),
351
+ messages: await convertToModelMessages(messages, {
352
+ tools,
353
+ convertDataPart: (part) => part.type === "data-dopus-job" ? {
354
+ type: "text",
355
+ text: jobNotice(part.data.id)
356
+ } : void 0
357
+ }),
317
358
  tools,
318
359
  toolApproval,
319
360
  stopWhen: isStepCount(maxSteps),
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.5";
30
+ const VERSION = "0.2.7";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.5",
4
- "description": "Drop an AI agent into your existing Next.js app — your server functions become its tools.",
3
+ "version": "0.2.7",
4
+ "description": "A lightweight agent for Next.js: your existing server functions become its tools, run with the signed-in user's permissions.",
5
5
  "license": "MIT",
6
6
  "author": "akodm",
7
7
  "homepage": "https://github.com/akodm/dopus#readme",
@@ -24,7 +24,10 @@
24
24
  "openai",
25
25
  "anthropic",
26
26
  "gemini",
27
- "chatbot"
27
+ "chatbot",
28
+ "lightweight",
29
+ "app-router",
30
+ "tools"
28
31
  ],
29
32
  "type": "module",
30
33
  "engines": {
@@ -0,0 +1,35 @@
1
+ {
2
+ "ui/agent-chat.tsx": {
3
+ "ff662fe56e84cfd0": "0.1.0",
4
+ "89475c1e29eb011b": "0.2.2",
5
+ "af6a34c9f15d4bef": "0.2.5",
6
+ "e0ff5f54b6823dba": "0.2.6",
7
+ "a3542945427a3dfa": "0.2.7"
8
+ },
9
+ "ui/agent-chat.module.css": {
10
+ "8ecfa2904dee75e9": "0.1.0"
11
+ },
12
+ "prompts/presets/general.ts": {
13
+ "10068289d8dfbf09": "0.1.0",
14
+ "fc75d0ecdeb38b7f": "0.2.2"
15
+ },
16
+ "prompts/presets/customer-support.ts": {
17
+ "2fdd5a26b25c23ae": "0.1.0",
18
+ "95bd1451466fb32b": "0.2.2"
19
+ },
20
+ "prompts/presets/internal-ops.ts": {
21
+ "2b150cc147d78e44": "0.1.0",
22
+ "c4b308fa69493fb8": "0.2.2",
23
+ "d5e08199cf6b45c2": "0.2.3"
24
+ },
25
+ "route": {
26
+ "17498e892a470ee4": "0.1.0",
27
+ "c44baa5927649535": "0.2.4"
28
+ },
29
+ "ui/agent-chat.css": {
30
+ "72f30cfb90fe5255": "0.2.2",
31
+ "65d193ef3df15ecc": "0.2.5",
32
+ "a33f338eb017ad66": "0.2.6",
33
+ "155c72a570d1ee5e": "0.2.7"
34
+ }
35
+ }
@@ -142,6 +142,12 @@
142
142
  color: var(--dopus-muted);
143
143
  }
144
144
 
145
+ .dopus-notice {
146
+ align-self: center;
147
+ color: var(--dopus-muted);
148
+ font-size: 0.8125rem;
149
+ }
150
+
145
151
  .dopus-error {
146
152
  color: var(--dopus-danger);
147
153
  }
@@ -251,6 +257,7 @@
251
257
  }
252
258
 
253
259
  .dopus-launcher {
260
+ position: relative;
254
261
  width: 52px;
255
262
  height: 52px;
256
263
  border-radius: 50%;
@@ -262,6 +269,20 @@
262
269
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
263
270
  }
264
271
 
272
+ /* A reply arrived while the panel was closed (e.g. a background job's follow-up). */
273
+ .dopus-launcher[data-unread]::after {
274
+ content: "";
275
+ position: absolute;
276
+ top: 2px;
277
+ right: 2px;
278
+ width: 12px;
279
+ height: 12px;
280
+ border-radius: 50%;
281
+ /* The launcher sits outside .dopus-chat, where the --dopus-* variables live, so its colors are fixed too. */
282
+ background: #dc2626;
283
+ border: 2px solid #ffffff;
284
+ }
285
+
265
286
  /* Assistant replies (Markdown). Compact spacing for a chat bubble. */
266
287
  .dopus-markdown {
267
288
  white-space: normal;
@@ -4,6 +4,7 @@
4
4
  * Chat UI for the dopus agent. Copied by `dopus init`; this file is yours to edit.
5
5
  * Talks to POST /api/agent with the AI SDK `useChat` protocol.
6
6
  * Styles: ./agent-chat.css — plain global CSS with `dopus-` class names (works with or without Tailwind).
7
+ * Background jobs: pass a `channel` and the agent continues by itself when a job it started finishes.
7
8
  */
8
9
  import { useChat } from '@ai-sdk/react'
9
10
  import {
@@ -19,6 +20,7 @@ import {
19
20
  type FormEvent,
20
21
  type KeyboardEvent,
21
22
  useEffect,
23
+ useMemo,
22
24
  useRef,
23
25
  useState,
24
26
  } from 'react'
@@ -44,7 +46,10 @@ const DEFAULT_LABELS = {
44
46
  declined: 'Declined',
45
47
  failed: 'Failed',
46
48
  details: 'Details',
49
+ jobRunning: 'Working in the background',
50
+ jobDone: 'Background task finished',
47
51
  open: 'Open assistant',
52
+ unread: 'New reply',
48
53
  close: 'Close assistant',
49
54
  }
50
55
 
@@ -57,6 +62,37 @@ export interface AgentChatProps {
57
62
  /** Example prompts shown before the first message. */
58
63
  suggestions?: string[]
59
64
  className?: string
65
+ /**
66
+ * Where the app announces finished background jobs (Supabase Realtime, a WebSocket, SSE, polling...).
67
+ * When a job started in this chat finishes, the agent is told and continues by itself. Create the
68
+ * channel once (module scope or `useMemo`) in a client component; it holds functions, so it cannot be
69
+ * passed from a server component.
70
+ */
71
+ channel?: AgentChannel
72
+ /** Follow-up turns the agent may take in a row on finished jobs before waiting for the user. Default 3. */
73
+ maxJobFollowUps?: number
74
+ /** Called when the agent finishes a reply. `AgentWidget` uses it to mark the closed launcher as unread. */
75
+ onReply?: () => void
76
+ }
77
+
78
+ export interface AgentChannel {
79
+ /** Start listening and call `finished(jobId)` for each job that finishes. Return a function that stops. */
80
+ subscribe: (finished: (jobId: string) => void) => () => void
81
+ }
82
+
83
+ // Must match the dopus server: a job tool's output carries `dopusJob: <id>`, and a finished job is reported
84
+ // back as a `data-dopus-job` part. The part carries only the ID; the server writes the text the model sees.
85
+ const JOB_KEY = 'dopusJob'
86
+ const JOB_PART = 'data-dopus-job'
87
+
88
+ function startedJob(part: UIMessage['parts'][number]): string | undefined {
89
+ if (!isToolUIPart(part) || part.state !== 'output-available') return undefined
90
+ const id = (part.output as Record<string, unknown> | null)?.[JOB_KEY]
91
+ return typeof id === 'string' ? id : undefined
92
+ }
93
+
94
+ function isJobNotice(message: UIMessage) {
95
+ return message.role === 'user' && message.parts.every((part) => part.type === JOB_PART)
60
96
  }
61
97
 
62
98
  type ToolPart = Extract<UIMessage['parts'][number], { toolCallId: string }>
@@ -78,12 +114,21 @@ function ToolCall({
78
114
  part,
79
115
  labels,
80
116
  onRespond,
117
+ waiting,
81
118
  }: {
82
119
  part: ToolPart
83
120
  labels: AgentChatLabels
84
121
  onRespond: (approvalId: string, approved: boolean) => void
122
+ waiting: boolean
85
123
  }) {
86
124
  const name = humanize(getToolName(part))
125
+ if (waiting) {
126
+ return (
127
+ <div className="dopus-tool" data-state="running">
128
+ <span className="dopus-spinner" aria-hidden /> {labels.jobRunning}: {name}
129
+ </div>
130
+ )
131
+ }
87
132
 
88
133
  switch (part.state) {
89
134
  case 'input-streaming':
@@ -187,11 +232,16 @@ function Message({
187
232
  message,
188
233
  labels,
189
234
  onRespond,
235
+ waitingJobs,
190
236
  }: {
191
237
  message: UIMessage
192
238
  labels: AgentChatLabels
193
239
  onRespond: (approvalId: string, approved: boolean) => void
240
+ waitingJobs: Set<string>
194
241
  }) {
242
+ if (isJobNotice(message)) {
243
+ return <div className="dopus-notice">✓ {labels.jobDone}</div>
244
+ }
195
245
  return (
196
246
  <div className="dopus-message" data-role={message.role}>
197
247
  {message.parts.map((part, index) => {
@@ -207,7 +257,16 @@ function Message({
207
257
  )
208
258
  }
209
259
  if (isToolUIPart(part)) {
210
- return <ToolCall key={key} part={part} labels={labels} onRespond={onRespond} />
260
+ const job = startedJob(part)
261
+ return (
262
+ <ToolCall
263
+ key={key}
264
+ part={part}
265
+ labels={labels}
266
+ onRespond={onRespond}
267
+ waiting={job !== undefined && waitingJobs.has(job)}
268
+ />
269
+ )
211
270
  }
212
271
  return null
213
272
  })}
@@ -220,9 +279,17 @@ export function AgentChat({
220
279
  labels: labelOverrides,
221
280
  suggestions = [],
222
281
  className,
282
+ channel,
283
+ maxJobFollowUps = 3,
284
+ onReply,
223
285
  }: AgentChatProps) {
224
286
  const labels = { ...DEFAULT_LABELS, ...labelOverrides }
225
287
  const router = useRouter()
288
+ // useChat keeps the callbacks it was created with; read the latest onReply through a ref.
289
+ const onReplyRef = useRef(onReply)
290
+ useEffect(() => {
291
+ onReplyRef.current = onReply
292
+ })
226
293
  const [transport] = useState(
227
294
  () =>
228
295
  new DefaultChatTransport({
@@ -250,12 +317,62 @@ export function AgentChat({
250
317
  if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
251
318
  router.refresh()
252
319
  }
320
+ onReplyRef.current?.()
253
321
  },
254
322
  })
255
323
  const [input, setInput] = useState('')
256
324
  const listRef = useRef<HTMLDivElement>(null)
257
325
  const busy = status === 'submitted' || status === 'streaming'
258
326
 
327
+ // Jobs the channel reported finished. Kept even before this chat shows the job (a job can finish before
328
+ // its tool output has streamed in); only IDs this chat started are acted on.
329
+ const [finishedJobs, setFinishedJobs] = useState<Set<string>>(() => new Set())
330
+ useEffect(() => {
331
+ if (!channel) return
332
+ return channel.subscribe((id) => setFinishedJobs((prev) => new Set(prev).add(id)))
333
+ }, [channel])
334
+
335
+ // Derived from the messages, so a new chat (or another tab) never reacts to jobs it did not start.
336
+ const { waitingJobs, readyJobs } = useMemo(() => {
337
+ const started = new Set<string>()
338
+ const reported = new Set<string>()
339
+ for (const message of messages) {
340
+ for (const part of message.parts) {
341
+ const id = startedJob(part)
342
+ if (id) started.add(id)
343
+ if (part.type === JOB_PART) reported.add((part.data as { id: string }).id)
344
+ }
345
+ }
346
+ const waiting = new Set([...started].filter((id) => !reported.has(id)))
347
+ return { waitingJobs: waiting, readyJobs: [...waiting].filter((id) => finishedJobs.has(id)) }
348
+ }, [messages, finishedJobs])
349
+
350
+ useEffect(() => {
351
+ if (readyJobs.length === 0 || status !== 'ready') return
352
+ const last = messages.at(-1)
353
+ // Wait while an approval card is open: the user answers that first.
354
+ if (last?.parts.some((part) => isToolUIPart(part) && part.state === 'approval-requested'))
355
+ return
356
+ const notice = {
357
+ role: 'user' as const,
358
+ parts: readyJobs.map(
359
+ (id) => ({ type: JOB_PART, data: { id } }) as UIMessage['parts'][number],
360
+ ),
361
+ }
362
+ // Follow-ups since the user last wrote; past the cap, the notice waits in the history for the user's next message.
363
+ let followUps = 0
364
+ for (const message of [...messages].reverse()) {
365
+ if (message.role !== 'user') continue
366
+ if (!isJobNotice(message)) break
367
+ followUps++
368
+ }
369
+ if (followUps >= maxJobFollowUps) {
370
+ setMessages([...messages, { id: `job-${readyJobs.join('-')}`, ...notice }])
371
+ } else {
372
+ sendMessage(notice)
373
+ }
374
+ }, [readyJobs, status, messages, maxJobFollowUps, sendMessage, setMessages])
375
+
259
376
  // biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever messages change
260
377
  useEffect(() => {
261
378
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
@@ -322,7 +439,13 @@ export function AgentChat({
322
439
  </div>
323
440
  )}
324
441
  {messages.map((message) => (
325
- <Message key={message.id} message={message} labels={labels} onRespond={respond} />
442
+ <Message
443
+ key={message.id}
444
+ message={message}
445
+ labels={labels}
446
+ onRespond={respond}
447
+ waitingJobs={waitingJobs}
448
+ />
326
449
  ))}
327
450
  {status === 'submitted' && <div className="dopus-thinking">{labels.thinking}</div>}
328
451
  {error && (
@@ -365,19 +488,34 @@ export function AgentChat({
365
488
  /** Floating button that opens the chat in a panel. Put it once in your root layout. */
366
489
  export function AgentWidget(props: AgentChatProps) {
367
490
  const [open, setOpen] = useState(false)
491
+ // A reply finished while the panel was closed, e.g. the agent continuing after a background job.
492
+ const [unread, setUnread] = useState(false)
368
493
  const labels = { ...DEFAULT_LABELS, ...props.labels }
369
494
  return (
370
495
  <div className="dopus-widget">
371
496
  {/* Hidden rather than unmounted, so closing the panel keeps the conversation. */}
372
497
  <div hidden={!open}>
373
- <AgentChat {...props} className="dopus-panel" />
498
+ <AgentChat
499
+ {...props}
500
+ className="dopus-panel"
501
+ onReply={() => {
502
+ if (!open) setUnread(true)
503
+ props.onReply?.()
504
+ }}
505
+ />
374
506
  </div>
375
507
  <button
376
508
  type="button"
377
509
  className="dopus-launcher"
378
510
  aria-expanded={open}
379
- aria-label={open ? labels.close : labels.open}
380
- onClick={() => setOpen((value) => !value)}
511
+ aria-label={
512
+ open ? labels.close : unread ? `${labels.open} (${labels.unread})` : labels.open
513
+ }
514
+ data-unread={!open && unread ? '' : undefined}
515
+ onClick={() => {
516
+ setOpen((value) => !value)
517
+ setUnread(false)
518
+ }}
381
519
  >
382
520
  {open ? '×' : '✦'}
383
521
  </button>