dopus 0.2.6 → 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
@@ -83,6 +83,27 @@ dopus init [--provider openai|anthropic|google] [--model <id>]
83
83
 
84
84
  `--yes` runs without prompts. It needs `--provider` and skips the API key prompt, so add the key to `.env.local` yourself afterwards.
85
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
+
86
107
  ## Tools
87
108
 
88
109
  ```ts
@@ -206,7 +227,9 @@ Anthropic gets an explicit cache breakpoint after layer 3. OpenAI and Gemini cac
206
227
  Both accept `api`, `suggestions`, `labels` (every UI string, for translation), `className`, and `channel` /
207
228
  `maxJobFollowUps` for [background jobs](#background-jobs).
208
229
 
209
- 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.
210
233
 
211
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.
212
235
 
@@ -490,8 +513,6 @@ dopus is pre-1.0, so expect breaking changes before 1.0. It is tested on Next.js
490
513
 
491
514
  Planned next, within the lightweight scope above:
492
515
 
493
- - a command that updates your copies of the chat UI and route when the templates change (it also brings background
494
- job support into projects created before it);
495
516
  - tool input schemas inferred from TypeScript types.
496
517
 
497
518
  ## Development
@@ -501,7 +522,8 @@ pnpm install
501
522
  pnpm build && pnpm lint && pnpm typecheck && pnpm test
502
523
  pnpm example:build && pnpm example:e2e # next start + scripted model
503
524
  pnpm example:live # real model from examples/next-app/.env.local (costs cents)
504
- 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`
505
527
  pnpm aws:emu # OpenNext build → local Lambda streaming invoke
506
528
  pnpm cf:e2e # dopus infra cloudflare → wrangler dev (workerd) → e2e
507
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-Dm8IBEUF.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
@@ -216,5 +216,5 @@ interface ProviderInfo {
216
216
  }
217
217
  /** Verified against @ai-sdk/openai@4.0.111, @ai-sdk/anthropic@4.0.99, @ai-sdk/google@4.0.114. */
218
218
  export declare const PROVIDERS: Record<ProviderId, ProviderInfo>;
219
- export declare const VERSION = "0.2.6";
219
+ export declare const VERSION = "0.2.7";
220
220
  export type { AgentConfig, AgentUser, ClientContext, ConfirmOption, DopusTool, DopusToolSet, FinishEvent, HandlerOptions, PromptLayers, ProviderId, ProviderInfo, RuntimeContext, ToolCallEvent, ToolContext, ToolOptions };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as PROVIDERS, t as VERSION } from "./version-Dm8IBEUF.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
3
  // src/job.ts
4
4
  /**
@@ -27,5 +27,5 @@ const PROVIDERS = {
27
27
  }
28
28
  };
29
29
  // src/version.ts
30
- const VERSION = "0.2.6";
30
+ const VERSION = "0.2.7";
31
31
  export { PROVIDERS as n, VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dopus",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
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",
@@ -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
+ }
@@ -257,6 +257,7 @@
257
257
  }
258
258
 
259
259
  .dopus-launcher {
260
+ position: relative;
260
261
  width: 52px;
261
262
  height: 52px;
262
263
  border-radius: 50%;
@@ -268,6 +269,20 @@
268
269
  box-shadow: 0 6px 20px rgb(0 0 0 / 0.2);
269
270
  }
270
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
+
271
286
  /* Assistant replies (Markdown). Compact spacing for a chat bubble. */
272
287
  .dopus-markdown {
273
288
  white-space: normal;
@@ -49,6 +49,7 @@ const DEFAULT_LABELS = {
49
49
  jobRunning: 'Working in the background',
50
50
  jobDone: 'Background task finished',
51
51
  open: 'Open assistant',
52
+ unread: 'New reply',
52
53
  close: 'Close assistant',
53
54
  }
54
55
 
@@ -70,6 +71,8 @@ export interface AgentChatProps {
70
71
  channel?: AgentChannel
71
72
  /** Follow-up turns the agent may take in a row on finished jobs before waiting for the user. Default 3. */
72
73
  maxJobFollowUps?: number
74
+ /** Called when the agent finishes a reply. `AgentWidget` uses it to mark the closed launcher as unread. */
75
+ onReply?: () => void
73
76
  }
74
77
 
75
78
  export interface AgentChannel {
@@ -278,9 +281,15 @@ export function AgentChat({
278
281
  className,
279
282
  channel,
280
283
  maxJobFollowUps = 3,
284
+ onReply,
281
285
  }: AgentChatProps) {
282
286
  const labels = { ...DEFAULT_LABELS, ...labelOverrides }
283
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
+ })
284
293
  const [transport] = useState(
285
294
  () =>
286
295
  new DefaultChatTransport({
@@ -308,6 +317,7 @@ export function AgentChat({
308
317
  if (message.parts.some((part) => isToolUIPart(part) && part.state === 'output-available')) {
309
318
  router.refresh()
310
319
  }
320
+ onReplyRef.current?.()
311
321
  },
312
322
  })
313
323
  const [input, setInput] = useState('')
@@ -478,19 +488,34 @@ export function AgentChat({
478
488
  /** Floating button that opens the chat in a panel. Put it once in your root layout. */
479
489
  export function AgentWidget(props: AgentChatProps) {
480
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)
481
493
  const labels = { ...DEFAULT_LABELS, ...props.labels }
482
494
  return (
483
495
  <div className="dopus-widget">
484
496
  {/* Hidden rather than unmounted, so closing the panel keeps the conversation. */}
485
497
  <div hidden={!open}>
486
- <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
+ />
487
506
  </div>
488
507
  <button
489
508
  type="button"
490
509
  className="dopus-launcher"
491
510
  aria-expanded={open}
492
- aria-label={open ? labels.close : labels.open}
493
- 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
+ }}
494
519
  >
495
520
  {open ? '×' : '✦'}
496
521
  </button>