diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d3655e1..2558282 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,12 @@ jobs:
- name: Typecheck
run: npm run typecheck
+ - name: Typecheck web
+ run: npm run -w web typecheck
+
+ - name: Build web
+ run: npm run -w web build
+
- name: Lint
run: npm run lint
diff --git a/CLAUDE.md b/CLAUDE.md
index 8727382..b865c5a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,10 @@ Adapted in our own words from Andrej Karpathy's observations on LLM coding pitfa
**Agents** are human support staff. **Assistants** are AI actors. Never conflate them — in schema, code, docs, or prose.
+## UI fidelity (TJ, 2026-07-12)
+
+The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `Helpthread App.dc.html` in the "Helpthread Agent Inbox Design" project (the "Helpthread" design-system project carries the same components). **The dogfood site must match it exactly — the whole designed surface, not a subset.** Design-system files under `web/src/components/ds/` stay verbatim copies; improvements go upstream in the design project first. Any deviation — visual, copy, or interaction — requires TJ's explicit sign-off. Remaining gaps are tracked as the fidelity checklist on [HT-23](https://resonantiq.atlassian.net/browse/HT-23); the ticket is not done until the checklist is.
+
## Ecosystem
- **This repo**: engineering truth — charter, specs, ADRs, code.
diff --git a/biome.json b/biome.json
index 2f7b009..d0416dd 100644
--- a/biome.json
+++ b/biome.json
@@ -21,6 +21,24 @@
"preset": "recommended"
}
},
+ "overrides": [
+ {
+ "includes": ["web/src/components/ds/**"],
+ "linter": {
+ "rules": {
+ "correctness": { "noUnusedImports": "off" },
+ "complexity": { "useOptionalChain": "off" },
+ "style": { "useTemplate": "off" },
+ "suspicious": { "noExplicitAny": "off" },
+ "a11y": {
+ "noSvgWithoutTitle": "off",
+ "useKeyWithClickEvents": "off",
+ "noStaticElementInteractions": "off"
+ }
+ }
+ }
+ }
+ ],
"javascript": {
"formatter": {
"quoteStyle": "single",
diff --git a/package-lock.json b/package-lock.json
index af416be..e3ac5bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "helpthread",
"version": "0.0.0",
"license": "AGPL-3.0-only",
+ "workspaces": [
+ "web"
+ ],
"dependencies": {
"@electric-sql/pglite": "^0.5.4",
"mimetext": "^3.0.28",
@@ -403,7 +406,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -758,156 +760,820 @@
"openbsd"
],
"engines": {
- "node": ">=18"
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@helpthread/web": {
+ "resolved": "web",
+ "link": true
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.20.tgz",
+ "integrity": "sha512-dXh51Wvddf8daEyBXryZZEe1FdVxEWx9lgaTseLZUtC1XP/W8Wri+Z+VPOElHlByk23CyqHdc2oVByX7wsTWsw==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.20.tgz",
+ "integrity": "sha512-in0yXG7/pRBVjWeEl7f7ZZETpletSMFKXVS4GJgHENTPVrJFNJKPrYewa9rpZcvdjwFece5fZP0CK34G4PxowA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.20.tgz",
+ "integrity": "sha512-0hsFshdPnTzGJdDTHeHJ+XPUShOpnyp9pUFDwDhqctsA0Cd8NcIVGRPtptYhgYY9DjkKgCDRkXxmgRc+CgT5Wg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/openbsd-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
- "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.20.tgz",
+ "integrity": "sha512-DMvkoBtAABOzE6pMZRW/xNm7sKqql3wzzzZJ1R/d/rp4BCxv6LykouD3tHjGY8WdQqGpZs11t+R9AtjPxvvljw==",
"cpu": [
- "x64"
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "openbsd"
+ "linux"
],
"engines": {
- "node": ">=18"
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
- "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.20.tgz",
+ "integrity": "sha512-RQmDfeYBtXV2FSId7dfA1hE6M/T6+g7wdbYnFQ47tw/gUBwV+CccLVejNmCGa9yLDitk83foeg8hl/3DjfYQ5g==",
"cpu": [
"arm64"
],
- "dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
- "openharmony"
+ "linux"
],
"engines": {
- "node": ">=18"
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/sunos-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
- "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.20.tgz",
+ "integrity": "sha512-DkWLEdKajJwdGt27M3i1VEO2kelTvZrK6Pcb7JvW2BY+nofWm7FBsBNDj7g7Pr1NuQ5PLJvqEqYa20GTsBDnKQ==",
"cpu": [
"x64"
],
- "dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
- "sunos"
+ "linux"
],
"engines": {
- "node": ">=18"
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/win32-arm64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
- "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.20.tgz",
+ "integrity": "sha512-rAO5b7pKHvX+ExdmJskusDXTNbiNZfptifIPZItbUx+AOXxxTydVBsPt7Oz84DRd5mY8e0DcE8kvLj3AIfjE6w==",
"cpu": [
- "arm64"
+ "x64"
+ ],
+ "libc": [
+ "musl"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
- "win32"
+ "linux"
],
"engines": {
- "node": ">=18"
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/win32-ia32": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
- "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.20.tgz",
+ "integrity": "sha512-Hp3zFsN8N8Kj9+vY6L4vnZ9EtA9eXyATu0q4EfGbZTiocgPUNSfz8NWhym6xvaOmHpJ8EuoypuU1WejCPsTFtg==",
"cpu": [
- "ia32"
+ "arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">=18"
+ "node": ">= 10"
}
},
- "node_modules/@esbuild/win32-x64": {
- "version": "0.28.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
- "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.20.tgz",
+ "integrity": "sha512-T/L7CXpR1M0wij/xbF3rT1+7KvSkfOLr7C+ToHHWZTG2eKmb52C5WvsyGCBNtkVvDEUESWkRUbbqSH4rSbOCYQ==",
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
- "node": ">=18"
- }
- },
- "node_modules/@jridgewell/resolve-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
- "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.5",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
- "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.31",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
- "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/resolve-uri": "^3.1.0",
- "@jridgewell/sourcemap-codec": "^1.4.14"
- }
- },
- "node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
- "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
- "dev": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.3"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
+ "node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
@@ -1209,6 +1875,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -1267,6 +1942,33 @@
"pg-types": "^2.2.0"
}
},
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@@ -1773,6 +2475,26 @@
"js-tokens": "^10.0.0"
}
},
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001805",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz",
+ "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1783,6 +2505,12 @@
"node": ">=18"
}
},
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1801,16 +2529,32 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
+ "node_modules/dompurify": {
+ "version": "3.4.12",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
+ "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
@@ -2334,7 +3078,6 @@
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -2349,6 +3092,86 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/next": {
+ "version": "15.5.20",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.20.tgz",
+ "integrity": "sha512-cvyS3/geydan1xLtE3FA8VCgdoQ/Gg/dlOldFkFCbB5VcVYJV7090hQLBnvTW2PwT76Z/dHdzDZCsVhZpoOlUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.20",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.20",
+ "@next/swc-darwin-x64": "15.5.20",
+ "@next/swc-linux-arm64-gnu": "15.5.20",
+ "@next/swc-linux-arm64-musl": "15.5.20",
+ "@next/swc-linux-x64-gnu": "15.5.20",
+ "@next/swc-linux-x64-musl": "15.5.20",
+ "@next/swc-win32-arm64-msvc": "15.5.20",
+ "@next/swc-win32-x64-msvc": "15.5.20",
+ "sharp": "^0.34.3"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/obug": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
@@ -2463,7 +3286,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
- "dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -2553,6 +3375,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -2587,11 +3430,17 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
+ "devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -2600,6 +3449,57 @@
"node": ">=10"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
+ "license": "MIT"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -2611,7 +3511,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
- "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -2640,6 +3539,29 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -2701,9 +3623,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.0",
@@ -2959,6 +3879,36 @@
"engines": {
"node": ">=0.4"
}
+ },
+ "web": {
+ "name": "@helpthread/web",
+ "version": "0.0.0",
+ "dependencies": {
+ "dompurify": "^3.2.4",
+ "next": "^15.5.12",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "server-only": "^0.0.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.4",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5.8.2"
+ }
+ },
+ "web/node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
}
}
}
diff --git a/package.json b/package.json
index d59e819..f533463 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"private": false,
"type": "module",
"license": "AGPL-3.0-only",
- "description": "Open-source, serverless helpdesk engine — shared inbox, threaded email, knowledge base — for teams who live on Vercel and Supabase.",
+ "description": "Open-source, serverless helpdesk engine \u2014 shared inbox, threaded email, knowledge base \u2014 for teams who live on Vercel and Supabase.",
"engines": {
"node": ">=20"
},
@@ -33,5 +33,8 @@
"tsx": "^4.23.0",
"typescript": "7.0.2",
"vitest": "4.1.10"
- }
+ },
+ "workspaces": [
+ "web"
+ ]
}
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..8b01cd3
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,4 @@
+.next/
+out/
+next-env.d.ts.bak
+*.tsbuildinfo
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..4f09cbe
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,73 @@
+# Helpthread — Agent Inbox UI (`@helpthread/web`)
+
+The Agent Inbox web app (HT-23): a Next.js client of the Agent Inbox API and
+nothing more — per CHARTER.md's API-first rule, anything this UI does, the
+typed public API does. It composes the design system handed back from the
+Claude Design project (`src/components/ds/**`, ported verbatim) into working
+screens.
+
+## Run it
+
+From the repo root, in two terminals:
+
+```sh
+npm run dev:api # the HT-24 harness: real engine, seeded data, :8787
+npm run -w web dev # the app on :3000
+```
+
+The app's dev defaults match the harness (`http://localhost:8787`,
+`helpthread-dev-token`). A deployment sets both env vars:
+
+- `HELPTHREAD_API_URL` — the Agent Inbox API's base URL.
+- `HELPTHREAD_API_TOKEN` — the service Bearer token. **Server-side only**: the
+ API client (`src/lib/api.ts`) imports `server-only`, so the token can never
+ reach a client bundle; every API call runs in a server component or server
+ action.
+
+## Where things live
+
+- `src/components/ds/**` — the design system, verbatim from the hand-back
+ (each component with its `.d.ts`). Edits belong upstream in the design
+ system, not here; a scoped biome override relaxes lint for these files.
+- `src/theme/` — the token/theme CSS chain. Rebrand = edit token values in
+ ONE place (`tokens/colors.css` accent + neutrals); no component changes.
+- `src/lib/api.ts` / `api-types.ts` — the typed API client, 1:1 with
+ `specs/api/agent-inbox-v1.md` (v1.1). `nextCursor` stays opaque; no mail
+ headers are ever composed client-side.
+- `src/lib/actions.ts` — server actions, the only write path.
+- `src/components/SanitizedHtml.tsx` — the ONE place inbound email HTML is
+ rendered: DOMPurify always (spec §5's stored-XSS contract), remote images
+ stripped.
+
+## The fidelity mandate (TJ, 2026-07-12)
+
+**The pixel source of truth is the Claude Design prototype** (`Helpthread
+App.dc.html` in the "Helpthread Agent Inbox Design" project), and this app is
+not done until it matches that design exactly — all of its surface, not a
+subset. Deviations of any kind (visual, copy, interaction) need TJ's explicit
+sign-off. See the "UI fidelity" section of the repo CLAUDE.md; the live gap
+list is the fidelity checklist on
+[HT-23](https://resonantiq.atlassian.net/browse/HT-23).
+
+## Shipped so far
+
+Persistent shell (folder rail + top bar, dark theme) · inbox folders
+(Unassigned/Mine/Starred/Drafts/Assigned/Closed/Spam, keyset load-older) ·
+conversation view with message bands (inbound / Agent reply / internal note
+/ failed-delivery / customer-viewed) and the Customer context panel · tag
+editing, assignee/Mine, star, four-state status (active/pending/closed/spam),
+soft delete (two-step arm), toasts · a SUMMONED composer (hidden by default;
+opens via the toolbar, `r`/`n`, or automatically when a saved draft exists)
+with Reply/Note tabs, rich-text formatting (bold/italic/list/link, sent as
+HTML alongside plain text), the closed-reopens-on-reply banner, localStorage
+draft persistence (debounced, backing the Drafts folder), and the spec §4a
+idempotency contract (one key per logical send, reused on retry, a fresh one
+after a validation failure; honest send-failure copy with retry) · keyboard
+shortcuts throughout (inbox j/k/Enter/x; conversation j/k/r/n/⌘+↵/cascading
+Escape; global `?` overlay).
+
+Still not wired: Forward, Merge, composing a NEW conversation from scratch
+("New message"), and the Agent's own profile settings — all spec'd for v1
+but not yet implemented. See the fidelity checklist on
+[HT-23](https://resonantiq.atlassian.net/browse/HT-23) for the authoritative
+list of remaining gaps.
diff --git a/web/next-env.d.ts b/web/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/web/next.config.mjs b/web/next.config.mjs
new file mode 100644
index 0000000..6353dbd
--- /dev/null
+++ b/web/next.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ // The engine is Node-bound (node:crypto HMAC) and so is this app's API
+ // access; nothing here targets the Edge runtime.
+ reactStrictMode: true,
+}
+
+export default nextConfig
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..67c7f64
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@helpthread/web",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Helpthread Agent Inbox \u2014 the UI client of the Agent Inbox API (HT-23; API-first per CHARTER.md).",
+ "type": "module",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "typecheck": "tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "dompurify": "^3.2.4",
+ "next": "^15.5.12",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "server-only": "^0.0.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.4",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5.8.2"
+ }
+}
diff --git a/web/src/app/(shell)/conversations/[id]/page.tsx b/web/src/app/(shell)/conversations/[id]/page.tsx
new file mode 100644
index 0000000..2bb4a3a
--- /dev/null
+++ b/web/src/app/(shell)/conversations/[id]/page.tsx
@@ -0,0 +1,54 @@
+import { notFound } from 'next/navigation'
+import { ConversationScreen } from '../../../../components/ConversationScreen'
+import { ApiError, getConversation, listConversations } from '../../../../lib/api'
+
+/**
+ * One conversation — server-fetched, client-rendered. A 404 from the API
+ * (missing or deleted — indistinguishable, spec §3b) is the app's 404.
+ *
+ * Also fetches the open folder's first 50 (a lightweight stand-in for "the
+ * current folder" — the toolbar doesn't otherwise know which of the seven
+ * app folders the Agent arrived from) to derive prev/next neighbors and a
+ * "{i} of {n}" position. If this conversation isn't in that page (closed,
+ * spam, or past the first 50), the toolbar just hides prev/next.
+ *
+ * Also fetches the closed folder's first 50 and, together with the open
+ * page, derives the context panel's "Previous conversations" list: every
+ * other conversation from the same customer, newest-updated first.
+ */
+export default async function ConversationPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+
+ try {
+ const conversation = await getConversation(id)
+ const [openPage, closedPage] = await Promise.all([
+ listConversations({ folder: 'open', limit: 50 }),
+ listConversations({ folder: 'closed', limit: 50 }),
+ ])
+ const ids = openPage.conversations.map((c) => c.id)
+ const index = ids.indexOf(id)
+ const position =
+ index === -1
+ ? null
+ : {
+ index: index + 1,
+ total: ids.length,
+ prevId: index > 0 ? (ids[index - 1] ?? null) : null,
+ nextId: index < ids.length - 1 ? (ids[index + 1] ?? null) : null,
+ }
+ const previousConversations = [...openPage.conversations, ...closedPage.conversations]
+ .filter((c) => c.customerEmail === conversation.customerEmail && c.id !== id)
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
+ .map((c) => ({ id: c.id, subject: c.subject, status: c.status, updatedAt: c.updatedAt }))
+ return (
+
+ )
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) notFound()
+ throw error
+ }
+}
diff --git a/web/src/app/(shell)/error.tsx b/web/src/app/(shell)/error.tsx
new file mode 100644
index 0000000..37f14f8
--- /dev/null
+++ b/web/src/app/(shell)/error.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import { AppError } from '../../components/AppError'
+
+/** Error boundary for the shell (inbox + conversation screens). See `AppError`. */
+export default function ShellError(props: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ return
+}
diff --git a/web/src/app/(shell)/inbox/[folder]/loading.tsx b/web/src/app/(shell)/inbox/[folder]/loading.tsx
new file mode 100644
index 0000000..f59d36a
--- /dev/null
+++ b/web/src/app/(shell)/inbox/[folder]/loading.tsx
@@ -0,0 +1,50 @@
+import { Skeleton } from '../../../../components/ds/core/Skeleton'
+import { ToolbarBand } from '../../../../components/ds/inbox/ToolbarBand'
+
+/**
+ * Inbox list loading state. The `(shell)` layout stays mounted across the
+ * navigation (folder rail, top bar unaffected) — only this work surface
+ * swaps in while the folder's conversations fetch.
+ */
+function SkeletonRow() {
+ return (
+
+ )
+}
+
+export default function InboxLoading() {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/src/app/(shell)/inbox/[folder]/page.tsx b/web/src/app/(shell)/inbox/[folder]/page.tsx
new file mode 100644
index 0000000..b7ad6ec
--- /dev/null
+++ b/web/src/app/(shell)/inbox/[folder]/page.tsx
@@ -0,0 +1,62 @@
+import { notFound, redirect } from 'next/navigation'
+import { InboxScreen } from '../../../../components/InboxScreen'
+import { listConversations } from '../../../../lib/api'
+import { isAppFolder } from '../../../../lib/folders'
+
+/**
+ * The inbox list — a server component: the API call (and its Bearer token)
+ * happens here, and the client screen receives plain data. `open` is a
+ * legacy alias that redirects to `unassigned` (its default view). `?cursor=`
+ * pages older conversations via the API's opaque keyset cursor (spec §3a),
+ * for the Closed and Spam folders only — the other five derive their view
+ * from a flat `open` fetch (Unassigned/Mine/Assigned split by `assignee`;
+ * Starred/Drafts filter an `open`+`closed` fetch against localStorage).
+ */
+export default async function InboxPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ folder: string }>
+ searchParams: Promise<{ cursor?: string }>
+}) {
+ const { folder } = await params
+ if (folder === 'open') redirect('/inbox/unassigned')
+ if (!isAppFolder(folder)) notFound()
+
+ switch (folder) {
+ case 'unassigned':
+ case 'mine':
+ case 'assigned': {
+ const page = await listConversations({ folder: 'open', limit: 50 })
+ return
+ }
+
+ case 'starred':
+ case 'drafts': {
+ const [openPage, closedPage] = await Promise.all([
+ listConversations({ folder: 'open', limit: 50 }),
+ listConversations({ folder: 'closed', limit: 50 }),
+ ])
+ const conversations = [...openPage.conversations, ...closedPage.conversations].sort(
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
+ )
+ return
+ }
+
+ case 'closed':
+ case 'spam': {
+ const { cursor } = await searchParams
+ const page = await listConversations({
+ folder,
+ ...(cursor !== undefined ? { cursor } : {}),
+ })
+ return (
+
+ )
+ }
+ }
+}
diff --git a/web/src/app/(shell)/layout.tsx b/web/src/app/(shell)/layout.tsx
new file mode 100644
index 0000000..75567e2
--- /dev/null
+++ b/web/src/app/(shell)/layout.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from 'react'
+import { FolderNav } from '../../components/FolderNav'
+import { loadFolderCounts } from '../../lib/folder-counts'
+
+/**
+ * The app shell shared by the inbox and conversation routes: the persistent
+ * folder rail on the warm-paper canvas, with each screen as the elevated
+ * work surface beside it (the design system's layer model). The rail's
+ * counts and support address are fetched here (server-side, with the
+ * Bearer token) and handed to the client `FolderNav` as props.
+ */
+export default async function ShellLayout({ children }: { children: ReactNode }) {
+ const counts = await loadFolderCounts()
+ const supportAddress = process.env.HELPTHREAD_SUPPORT_ADDRESS ?? 'support@dev.localhost'
+
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..bb0ac49
--- /dev/null
+++ b/web/src/app/dashboard/page.tsx
@@ -0,0 +1,14 @@
+import { DashboardScreen } from '../../components/DashboardScreen'
+import { loadFolderCounts } from '../../lib/folder-counts'
+
+/**
+ * /dashboard — a plain top-level route (no folder rail; the design puts the
+ * mailbox overview outside the shell, same as `/settings`). The wordmark
+ * and the top bar's Mailbox tab both land here.
+ */
+export default async function DashboardPage() {
+ const counts = await loadFolderCounts()
+ const supportAddress = process.env.HELPTHREAD_SUPPORT_ADDRESS ?? 'support@dev.localhost'
+
+ return
+}
diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx
new file mode 100644
index 0000000..187d7ca
--- /dev/null
+++ b/web/src/app/error.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+/**
+ * Top-level route error boundary — the fallback for any route NOT inside a
+ * more specific boundary (notably `/dashboard`, which lives outside the
+ * `(shell)` group). Routes a 401 (tagged via `error.digest`) to the designed
+ * AuthFailure screen, like the segment boundaries do. It does NOT catch errors
+ * thrown by the ROOT LAYOUT itself (only a global-error boundary can) — but the
+ * layout is now resilient (its one fetch is swallowed on failure), so nothing
+ * there throws, and the errors that reach here come from page components.
+ */
+
+import { AppError } from '../components/AppError'
+
+export default function RootError(props: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ return
+}
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
new file mode 100644
index 0000000..fb614ce
--- /dev/null
+++ b/web/src/app/layout.tsx
@@ -0,0 +1,77 @@
+import type { Metadata } from 'next'
+import type { ReactNode } from 'react'
+import '../theme/helpthread.css'
+import { ShortcutsProvider } from '../components/ShortcutsProvider'
+import { ThemeProvider } from '../components/ThemeProvider'
+import { ToasterProvider } from '../components/Toaster'
+import { TopBar } from '../components/TopBar'
+import { listConversations } from '../lib/api'
+import type { ConversationSummary } from '../lib/api-types'
+import { THEME_INIT_SCRIPT } from '../lib/theme'
+
+export const metadata: Metadata = {
+ title: 'Helpthread',
+ description: 'Helpthread Agent Inbox',
+}
+
+/**
+ * The app shell: the accent-filled top bar — the design system's ONE colored
+ * surface — carrying the wordmark (plain text, serif, muted dot; there is no
+ * logo) and the Mailbox/Manage/Notifications/Agent menus (`TopBar`), over the
+ * warm-paper canvas everything else sits on. Stays a server component: the
+ * Notifications panel's data (6 most recent open conversations) is fetched
+ * here, with the Bearer token, and handed to the client `TopBar` as props.
+ */
+export default async function RootLayout({ children }: { children: ReactNode }) {
+ // The notifications bell is non-critical chrome. Its fetch MUST NOT be able
+ // to take down the whole app: an error thrown here (e.g. a bad token
+ // surfacing as 401) would escape ABOVE every route error boundary — the
+ // boundaries live under this layout — so the designed AuthFailure screen
+ // would never render and the app would hard-crash instead. Swallowing it to
+ // an empty bell lets the SAME 401 resurface from the page-level fetch, which
+ // the route error boundaries DO catch and route to AuthFailure.
+ let recentOpen: ConversationSummary[] = []
+ try {
+ recentOpen = (await listConversations({ folder: 'open', limit: 6 })).conversations
+ } catch {
+ // Empty bell; the real error surfaces (and is handled) at the page level.
+ }
+
+ return (
+ // suppressHydrationWarning: the inline script below sets `data-theme` on
+ // this element BEFORE React hydrates (that's the whole point — it avoids
+ // a flash of the wrong theme), so the attribute React sees during
+ // hydration legitimately differs from what it server-rendered. This is
+ // the standard fix for this exact pattern (see e.g. next-themes); it
+ // only suppresses the warning for this one element's own attributes; it
+ // has no effect on children.
+
+
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: pre-hydration
+ theme apply, must run standalone before any bundle loads — see
+ lib/theme.ts */}
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ )
+}
diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx
new file mode 100644
index 0000000..9374e11
--- /dev/null
+++ b/web/src/app/not-found.tsx
@@ -0,0 +1,32 @@
+import Link from 'next/link'
+import { EmptyState } from '../components/ds/core/EmptyState'
+
+/**
+ * Global 404 (fidelity checklist). Root-level so it also catches an
+ * unmatched route; a `notFound()` thrown inside `(shell)` (e.g. an unknown
+ * folder) bubbles up here too, since that segment has no not-found of its
+ * own — which also means the folder rail doesn't render alongside it.
+ */
+export default function NotFound() {
+ return (
+
+
+
+
+ Back to inbox
+
+
+
+ )
+}
diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
new file mode 100644
index 0000000..c0aaf40
--- /dev/null
+++ b/web/src/app/page.tsx
@@ -0,0 +1,6 @@
+import { redirect } from 'next/navigation'
+
+/** The inbox IS the app — land in the open folder. */
+export default function Home() {
+ redirect('/inbox/open')
+}
diff --git a/web/src/app/settings/error.tsx b/web/src/app/settings/error.tsx
new file mode 100644
index 0000000..8bd0c7f
--- /dev/null
+++ b/web/src/app/settings/error.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import { AppError } from '../../components/AppError'
+
+/** Error boundary for /settings. See `AppError`. */
+export default function SettingsError(props: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ return
+}
diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx
new file mode 100644
index 0000000..730e4fd
--- /dev/null
+++ b/web/src/app/settings/page.tsx
@@ -0,0 +1,19 @@
+import { SettingsScreen } from '../../components/SettingsScreen'
+
+/**
+ * Settings — a plain top-level route (no folder rail; the design deliberately
+ * puts it outside the shell). The Deployment card is read-only display: this
+ * app has no API affordance for these values (they're engine-side deploy
+ * config, spec's `mailDomain`/`supportAddress` deps), so this server
+ * component reads them from its own env — falling back to the same dev
+ * defaults the local harness uses (`scripts/dev-api.ts`) when unset.
+ */
+export default function SettingsPage() {
+ const deployment = {
+ productName: 'Helpthread',
+ supportAddress: process.env.HELPTHREAD_SUPPORT_ADDRESS ?? 'support@dev.localhost',
+ mailDomain: process.env.HELPTHREAD_MAIL_DOMAIN ?? 'mail.dev.localhost',
+ }
+
+ return
+}
diff --git a/web/src/components/AppError.tsx b/web/src/components/AppError.tsx
new file mode 100644
index 0000000..f1fda52
--- /dev/null
+++ b/web/src/components/AppError.tsx
@@ -0,0 +1,60 @@
+'use client'
+
+/**
+ * Shared body for the route error boundaries (`app/(shell)/error.tsx`,
+ * `app/settings/error.tsx`). A 401 from `lib/api.ts` is tagged with
+ * `error.digest === AUTH_ERROR_DIGEST` — the channel that survives Next.js's
+ * production sanitization of Server Component errors (which strips `message`)
+ * — and that selects the AuthFailure screen instead of the generic fallback
+ * below. The `unauthorized:` message prefix is a dev-only fallback.
+ */
+
+import { AUTH_ERROR_DIGEST } from '../lib/auth-error'
+import { AuthFailure } from './AuthFailure'
+import { Button } from './ds/core/Button'
+
+export function AppError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ if (error.digest === AUTH_ERROR_DIGEST || error.message.startsWith('unauthorized:')) {
+ return
+ }
+
+ return (
+
+
+
The inbox couldn't load.
+
+ {error.message}
+
+
+
+ Try again
+
+
+
+
+ )
+}
diff --git a/web/src/components/AuthFailure.tsx b/web/src/components/AuthFailure.tsx
new file mode 100644
index 0000000..740349c
--- /dev/null
+++ b/web/src/components/AuthFailure.tsx
@@ -0,0 +1,95 @@
+'use client'
+
+/**
+ * Full-screen auth-failure state (fidelity checklist). Rendered by
+ * `AppError` when a thrown `ApiError` carries the auth-failure `error.digest`
+ * (see `lib/api.ts` and `lib/auth-error.ts`). `position: fixed; inset: 0` so it covers the
+ * whole viewport regardless of where in the tree it's rendered — a broken
+ * service token means nothing else on the page (folder rail included) is
+ * usable anyway.
+ */
+
+import { Button } from './ds/core/Button'
+
+export function AuthFailure() {
+ return (
+
+
+ helpthread.
+
+
+ Helpthread can’t reach your inbox
+
+
+ Every request is signed with this deployment’s service token, and the API just
+ rejected it. There’s nothing to sign into — this is configuration, not a login.
+
+
+ HELPTHREAD_API_TOKEN
+
+
+ Update the token in your deployment settings, redeploy, and reload this page.
+
+
+ window.location.reload()}>
+ Reload
+
+
+
+ )
+}
diff --git a/web/src/components/ConversationScreen.tsx b/web/src/components/ConversationScreen.tsx
new file mode 100644
index 0000000..fd249ee
--- /dev/null
+++ b/web/src/components/ConversationScreen.tsx
@@ -0,0 +1,1713 @@
+'use client'
+
+/**
+ * One conversation: toolbar (back, reply/note/delete, tags, star, more,
+ * assignee/status/position — subject/#number/status pill on a wrapped
+ * second row), a SUMMONED composer (hidden by default; opens via the
+ * toolbar Reply/Note buttons, the `r`/`n` keys, or automatically when a
+ * saved draft exists), and the thread as full-bleed MessageBands
+ * newest-first (design-system rule: bands, not chat bubbles).
+ *
+ * The composer implements spec §4a's client contract faithfully:
+ * - ONE Idempotency-Key per logical send, minted when the draft starts and
+ * reused verbatim on every retry — a 409 `retry_in_progress` or a network
+ * failure never mints a new key (that would risk a duplicate send). A
+ * `400 validation_failed` DOES mint a new key — the original attempt never
+ * reached the send path, so there is nothing to safely replay.
+ * - `send_failed` (502) keeps the draft and says exactly what is true:
+ * nothing reached the customer, with a "Retry send" action that reuses
+ * the same key.
+ * - Only a SUCCESS clears the draft and rotates the key.
+ *
+ * This screen also owns its own keyboard shortcuts (j/k conversation nav,
+ * r/n to open the composer, ⌘/Ctrl+↵ to send, and a cascading Escape) — see
+ * the keydown effect below, coordinated with `ShortcutsProvider`'s overlay.
+ */
+
+import { useRouter } from 'next/navigation'
+import type { CSSProperties } from 'react'
+import { useEffect, useRef, useState, useTransition } from 'react'
+import {
+ deleteConversationAction,
+ postNoteAction,
+ putAssigneeAction,
+ putTagsAction,
+ sendReplyAction,
+ setStatusAction,
+} from '../lib/actions'
+import type { ConversationDetail, ConversationStatus, ThreadView } from '../lib/api-types'
+import { clearDraft, getDraft, writeDraft } from '../lib/drafts'
+import { messageTime, nameFromEmail, relativeTime, shortDate } from '../lib/format'
+import { useStarred } from '../lib/starred'
+import { Avatar } from './ds/core/Avatar'
+import { Button } from './ds/core/Button'
+import { DropdownMenu } from './ds/core/DropdownMenu'
+import { IconButton } from './ds/core/IconButton'
+import { MenuItem } from './ds/core/MenuItem'
+import { StatusPill } from './ds/core/StatusPill'
+import { TagChip } from './ds/core/TagChip'
+import { TextInput } from './ds/core/TextInput'
+import { MessageBand } from './ds/inbox/MessageBand'
+import { ToolbarBand } from './ds/inbox/ToolbarBand'
+import { SanitizedHtml } from './SanitizedHtml'
+import { useShortcutsOverlay } from './ShortcutsProvider'
+import { useToast } from './Toaster'
+
+const MAX_REPLY_LENGTH = 5000
+const MAX_TAG_LENGTH = 40
+const DELETE_DISARM_MS = 3500
+const DRAFT_SAVE_DEBOUNCE_MS = 300
+
+/** Reply-mode formatting is honestly reported only when the Agent actually
+ * used the toolbar — checked by tag presence, not by diffing markup. */
+function hasRichFormatting(el: HTMLElement): boolean {
+ return el.querySelector('b, i, ul, a') !== null
+}
+
+/** contenteditable's `innerText` reports a trailing newline for the last
+ * (empty) line — trim only trailing whitespace, keep interior formatting. */
+function normalizedInnerText(el: HTMLElement): string {
+ return el.innerText.replace(/\s+$/, '')
+}
+
+function pillTabStyle(active: boolean): CSSProperties {
+ return {
+ border: 'none',
+ borderRadius: 999,
+ padding: '4px 12px',
+ fontSize: 12.5,
+ fontWeight: 700,
+ cursor: 'pointer',
+ background: active ? 'var(--ht-accent)' : 'var(--ht-surface-2)',
+ color: active ? 'var(--ht-on-accent)' : 'var(--ht-ink-muted)',
+ }
+}
+
+function counterStyle(length: number): CSSProperties {
+ const over = length > MAX_REPLY_LENGTH
+ return {
+ fontSize: 11.5,
+ fontVariantNumeric: 'tabular-nums',
+ fontWeight: over ? 700 : 400,
+ color: over ? 'var(--ht-critical)' : 'var(--ht-ink-dim)',
+ }
+}
+
+const WARN_BANNER_STYLE: CSSProperties = {
+ marginBottom: 10,
+ padding: '8px 12px',
+ borderRadius: 'var(--ht-radius-md)',
+ background: 'color-mix(in oklab, var(--ht-warn) 9%, var(--ht-surface))',
+ border: '1px solid color-mix(in oklab, var(--ht-warn) 30%, transparent)',
+ fontSize: 12.5,
+ color: 'var(--ht-ink-muted)',
+}
+
+const CRITICAL_BANNER_STYLE: CSSProperties = {
+ marginBottom: 10,
+ padding: '10px 14px',
+ borderRadius: 'var(--ht-radius-md)',
+ background: 'var(--ht-critical-soft)',
+ border: '1px solid color-mix(in oklab, var(--ht-critical) 28%, transparent)',
+}
+
+/** Plain borderless icon buttons on the format-toolbar row — the design has
+ * no grey pill/segmented group around them, just hover feedback. */
+const FORMAT_BUTTON_STYLE: CSSProperties = {
+ width: 26,
+ height: 26,
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: 12.5,
+ fontWeight: 700,
+ color: 'var(--ht-ink-muted)',
+ background: 'none',
+ border: 'none',
+ borderRadius: 'var(--ht-radius-sm)',
+ cursor: 'pointer',
+}
+
+function onFormatButtonHover(event: { currentTarget: HTMLElement }): void {
+ event.currentTarget.style.background = 'var(--ht-surface-2)'
+}
+
+function onFormatButtonUnhover(event: { currentTarget: HTMLElement }): void {
+ event.currentTarget.style.background = 'none'
+}
+
+const CONTENT_EDITABLE_STYLE: CSSProperties = {
+ minHeight: 84,
+ outline: 'none',
+ fontSize: 14,
+ lineHeight: 1.6,
+ color: 'var(--ht-ink)',
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'break-word',
+}
+
+export interface ConversationNeighborPosition {
+ index: number
+ total: number
+ prevId: string | null
+ nextId: string | null
+}
+
+/** A prior conversation from the same customer — the context panel's
+ * "Previous conversations" section (design fix C5). */
+export interface PreviousConversationSummary {
+ id: string
+ subject: string
+ status: ConversationStatus
+ updatedAt: string
+}
+
+const STATUS_META: Record = {
+ active: { label: 'Active', fg: 'var(--ht-accent)', bg: 'var(--ht-accent-soft)' },
+ pending: {
+ label: 'Pending',
+ fg: 'var(--ht-warn)',
+ bg: 'color-mix(in oklab, var(--ht-warn) 12%, transparent)',
+ },
+ closed: { label: 'Closed', fg: 'var(--ht-ink-dim)', bg: 'var(--ht-surface-2)' },
+ spam: { label: 'Spam', fg: 'var(--ht-critical)', bg: 'var(--ht-critical-soft)' },
+}
+
+function ReplyIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function NoteIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function TrashIcon() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+function TagIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function StarIcon({ filled }: { filled: boolean }) {
+ return (
+
+
+
+ )
+}
+
+function PersonIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function FlagIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function GearIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function MoreIcon() {
+ return (
+
+
+
+
+
+ )
+}
+
+function ChevronDownIcon() {
+ return (
+
+
+
+ )
+}
+
+function ChevronLeftIcon() {
+ return (
+
+
+
+ )
+}
+
+function ChevronRightIcon() {
+ return (
+
+
+
+ )
+}
+
+function BulletListIcon() {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+function LinkIcon() {
+ return (
+
+
+
+
+ )
+}
+
+function CloseIcon() {
+ return (
+
+
+
+
+ )
+}
+
+/** ⌄ per-message menu — Copy text / Show original. Positioned by the caller's wrapper div. */
+function MessageMenu({
+ open,
+ onToggle,
+ onClose,
+ onCopyText,
+ onShowOriginal,
+}: {
+ open: boolean
+ onToggle: () => void
+ onClose: () => void
+ onCopyText: () => void
+ onShowOriginal: () => void
+}) {
+ return (
+
+
+
+
+
+ Copy text
+ Show original
+
+
+ )
+}
+
+/**
+ * Fixed centered modal: the raw source, exactly as it arrived, never
+ * rendered as HTML. Escape is handled by the parent's cascading Escape
+ * handler (this modal has top priority in that cascade), not locally — a
+ * single owner avoids two listeners racing to close the same state.
+ */
+function OriginalMessageModal({ thread, onClose }: { thread: ThreadView; onClose: () => void }) {
+ const bodyKind =
+ thread.bodyHtml !== null && thread.bodyText !== null && thread.bodyText !== ''
+ ? 'HTML + text'
+ : thread.bodyHtml !== null
+ ? 'HTML only'
+ : thread.bodyText !== null
+ ? 'Text only'
+ : 'Empty'
+
+ const source = thread.bodyHtml ?? thread.bodyText ?? ''
+
+ return (
+ <>
+
+
+
+ Original message
+
+
+
+
+
+
+ From
+
+ {thread.from}
+
+ Date
+
+ {new Date(thread.createdAt).toLocaleString()}
+
+ Body kind
+ {bodyKind}
+
+
+ {source}
+
+
+ Shown as source, never rendered — this is the untrusted message exactly as it arrived.
+
+
+ >
+ )
+}
+
+export function ConversationScreen({
+ conversation,
+ position,
+ previousConversations,
+}: {
+ conversation: ConversationDetail
+ position: ConversationNeighborPosition | null
+ previousConversations: PreviousConversationSummary[]
+}) {
+ const router = useRouter()
+ const showToast = useToast()
+ const { isStarred, toggle } = useStarred()
+ const { isOpen: isShortcutsOverlayOpen } = useShortcutsOverlay()
+
+ const [isPending, startTransition] = useTransition()
+
+ // The summoned composer — hidden by default (spec: opens via the toolbar,
+ // r/n, or automatically when a saved draft exists).
+ const [composerOpen, setComposerOpen] = useState(false)
+ const [composerMode, setComposerMode] = useState<'reply' | 'note'>('reply')
+ const [composerError, setComposerError] = useState(null)
+ const [sendFailed, setSendFailed] = useState(false)
+ const [replyTextLength, setReplyTextLength] = useState(0)
+ const [noteDraft, setNoteDraft] = useState('')
+ // Bumped by the per-conversation reset below so the DOM-population effect
+ // re-runs even when `composerOpen` itself doesn't change value (both
+ // conversations have a draft, so it's `true` on both sides of a j/k nav).
+ const [composerGeneration, setComposerGeneration] = useState(0)
+
+ const replyBodyRef = useRef(null)
+ const noteTextareaRef = useRef(null)
+ // Plain text loaded from localStorage for the CURRENT conversation, and
+ // the live in-session HTML once the Agent has actually typed (so toggling
+ // the composer closed/open again within the same visit keeps formatting —
+ // only localStorage itself is plain-text-only, per the draft contract).
+ const replyDraftTextRef = useRef('')
+ const replyBodyHtmlRef = useRef(null)
+ const draftSaveTimer = useRef | null>(null)
+ // One key per logical send (spec §4a) — rotated on success, and on a
+ // validation failure (the original attempt never reached the send path).
+ const idempotencyKey = useRef(crypto.randomUUID())
+
+ const [status, setLocalStatus] = useState(conversation.status)
+ const [tags, setTags] = useState(conversation.tags)
+ const [assignee, setAssignee] = useState<'me' | null>(conversation.assignee)
+
+ const [tagInput, setTagInput] = useState('')
+ const [tagsMenuOpen, setTagsMenuOpen] = useState(false)
+ const [assigneeMenuOpen, setAssigneeMenuOpen] = useState(false)
+ const [statusMenuOpen, setStatusMenuOpen] = useState(false)
+ const [moreMenuOpen, setMoreMenuOpen] = useState(false)
+ const [following, setFollowing] = useState(false)
+ const [previousConversationsOpen, setPreviousConversationsOpen] = useState(true)
+
+ const [deleteArmed, setDeleteArmed] = useState(false)
+ const deleteDisarmTimer = useRef | null>(null)
+
+ const [openMessageMenuId, setOpenMessageMenuId] = useState(null)
+ const [originalMessage, setOriginalMessage] = useState(null)
+
+ useEffect(() => {
+ return () => {
+ if (deleteDisarmTimer.current !== null) clearTimeout(deleteDisarmTimer.current)
+ if (draftSaveTimer.current !== null) clearTimeout(draftSaveTimer.current)
+ }
+ }, [])
+
+ // Per-conversation reset — this component instance is reused across
+ // conversations (j/k, prev/next chevrons navigate without remounting), so
+ // this can't be a one-time mount effect. Loads that conversation's saved
+ // draft and auto-opens the composer when one exists.
+ useEffect(() => {
+ const existingDraft = getDraft(conversation.id) ?? ''
+ replyDraftTextRef.current = existingDraft
+ replyBodyHtmlRef.current = null
+ setReplyTextLength(existingDraft.length)
+ setNoteDraft('')
+ setComposerError(null)
+ setSendFailed(false)
+ idempotencyKey.current = crypto.randomUUID()
+ setComposerGeneration((generation) => generation + 1)
+ if (existingDraft.length > 0) {
+ setComposerMode('reply')
+ setComposerOpen(true)
+ } else {
+ setComposerOpen(false)
+ }
+ }, [conversation.id])
+
+ // Populate the (uncontrolled) contenteditable whenever the reply composer
+ // becomes visible — never on every keystroke, so the caret stays put.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: composerGeneration is the trigger, not read in the body
+ useEffect(() => {
+ if (!composerOpen || composerMode !== 'reply') return
+ const el = replyBodyRef.current
+ if (el === null) return
+ if (replyBodyHtmlRef.current !== null) {
+ el.innerHTML = replyBodyHtmlRef.current
+ } else {
+ el.textContent = replyDraftTextRef.current
+ }
+ setReplyTextLength(normalizedInnerText(el).length)
+ el.focus()
+ const range = document.createRange()
+ range.selectNodeContents(el)
+ range.collapse(false)
+ const selection = window.getSelection()
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ }, [composerOpen, composerMode, composerGeneration])
+
+ useEffect(() => {
+ if (composerOpen && composerMode === 'note') noteTextareaRef.current?.focus()
+ }, [composerOpen, composerMode])
+
+ function scheduleDraftSave(text: string): void {
+ if (draftSaveTimer.current !== null) clearTimeout(draftSaveTimer.current)
+ draftSaveTimer.current = setTimeout(() => {
+ writeDraft(conversation.id, text)
+ replyDraftTextRef.current = text
+ }, DRAFT_SAVE_DEBOUNCE_MS)
+ }
+
+ function onReplyBodyInput(): void {
+ const el = replyBodyRef.current
+ if (el === null) return
+ replyBodyHtmlRef.current = el.innerHTML
+ const text = normalizedInnerText(el)
+ setReplyTextLength(text.length)
+ setComposerError(null)
+ scheduleDraftSave(text)
+ }
+
+ // document.execCommand is deprecated, but it remains the only
+ // cross-browser way to drive a contenteditable's rich-text edits without
+ // pulling in a full editor library — exactly what the prototype does.
+ function applyFormat(command: 'bold' | 'italic' | 'insertUnorderedList'): void {
+ replyBodyRef.current?.focus()
+ document.execCommand(command)
+ onReplyBodyInput()
+ }
+
+ function applyLink(): void {
+ const url = window.prompt('Link URL')
+ if (url === null || url.trim().length === 0) return
+ replyBodyRef.current?.focus()
+ document.execCommand('createLink', false, url.trim())
+ onReplyBodyInput()
+ }
+
+ function openComposer(mode: 'reply' | 'note'): void {
+ setComposerMode(mode)
+ setComposerError(null)
+ setSendFailed(false)
+ setComposerOpen(true)
+ }
+
+ function closeComposer(): void {
+ setComposerOpen(false)
+ }
+
+ function switchComposerMode(mode: 'reply' | 'note'): void {
+ setComposerMode(mode)
+ setComposerError(null)
+ setSendFailed(false)
+ }
+
+ function sendReply(): void {
+ const el = replyBodyRef.current
+ const text = el !== null ? normalizedInnerText(el) : ''
+ if (text.trim().length < 1) {
+ setComposerError("Write a reply first — the message can't be empty.")
+ return
+ }
+ if (text.length > MAX_REPLY_LENGTH) {
+ setComposerError('Replies are limited to 5,000 characters.')
+ return
+ }
+ setComposerError(null)
+ setSendFailed(false)
+ const html = el !== null && hasRichFormatting(el) ? el.innerHTML : undefined
+ const wasClosedOrSpam = status === 'closed' || status === 'spam'
+ startTransition(async () => {
+ const result = await sendReplyAction(conversation.id, text, idempotencyKey.current, html)
+ if (result.ok) {
+ clearDraft(conversation.id)
+ replyDraftTextRef.current = ''
+ replyBodyHtmlRef.current = null
+ if (el !== null) el.innerHTML = ''
+ setReplyTextLength(0)
+ idempotencyKey.current = crypto.randomUUID()
+ setComposerOpen(false)
+ if (wasClosedOrSpam) {
+ setLocalStatus('active')
+ showToast({
+ title: 'Reply sent — conversation reopened',
+ detail: 'Replying to a closed conversation reopens it.',
+ })
+ } else {
+ showToast({ title: 'Reply sent' })
+ }
+ router.refresh()
+ return
+ }
+ // Honest failure copy per the design system's content rules; the
+ // draft is preserved in every outcome.
+ if (result.code === 'send_failed') {
+ setSendFailed(true)
+ return
+ }
+ if (result.code === 'retry_in_progress') {
+ setComposerError('This reply is already being sent. Give it a moment, then try again.')
+ return
+ }
+ if (result.code === 'validation_failed') {
+ // The original attempt never reached the send path — a replay key
+ // has nothing to safely replay, so mint a fresh one.
+ idempotencyKey.current = crypto.randomUUID()
+ }
+ setComposerError(result.message ?? 'Something went wrong. The draft is preserved.')
+ })
+ }
+
+ function sendNote(): void {
+ if (noteDraft.trim().length < 1) {
+ setComposerError("Write a note first — the note can't be empty.")
+ return
+ }
+ if (noteDraft.length > MAX_REPLY_LENGTH) {
+ setComposerError('Notes are limited to 5,000 characters.')
+ return
+ }
+ setComposerError(null)
+ startTransition(async () => {
+ const result = await postNoteAction(conversation.id, noteDraft)
+ if (result.ok) {
+ setNoteDraft('')
+ setComposerOpen(false)
+ showToast({ title: 'Note added', detail: 'Visible to Agents only — never emailed.' })
+ router.refresh()
+ return
+ }
+ setComposerError(result.message ?? 'Something went wrong.')
+ })
+ }
+
+ function sendActive(): void {
+ if (composerMode === 'reply') sendReply()
+ else sendNote()
+ }
+
+ // The screen's own shortcuts: j/k conversation nav, r/n open the
+ // composer, ⌘/Ctrl+↵ sends, Escape cascades (original-message modal → any
+ // open menu → composer, draft kept → back to the inbox). The shortcuts
+ // overlay's own Escape handler doesn't stop propagation, so this skips
+ // entirely while it's open — otherwise one Escape press would both close
+ // the overlay AND cascade through this screen's own close logic.
+ //
+ // Kept in a ref (the "latest closure" pattern) rather than the effect's
+ // dependency array: nearly every piece of UI state on this screen affects
+ // this handler, and the DOM listener itself doesn't need to churn on each
+ // one — only the closure it calls does.
+ const onKeyDownRef = useRef<(event: KeyboardEvent) => void>(() => {})
+ onKeyDownRef.current = (event: KeyboardEvent) => {
+ if (isShortcutsOverlayOpen) return
+
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && composerOpen) {
+ event.preventDefault()
+ sendActive()
+ return
+ }
+
+ if (event.key === 'Escape') {
+ if (originalMessage !== null) {
+ setOriginalMessage(null)
+ return
+ }
+ if (
+ tagsMenuOpen ||
+ assigneeMenuOpen ||
+ statusMenuOpen ||
+ moreMenuOpen ||
+ openMessageMenuId !== null
+ ) {
+ setTagsMenuOpen(false)
+ setAssigneeMenuOpen(false)
+ setStatusMenuOpen(false)
+ setMoreMenuOpen(false)
+ setOpenMessageMenuId(null)
+ return
+ }
+ if (composerOpen) {
+ closeComposer()
+ return
+ }
+ router.push('/inbox/open')
+ return
+ }
+
+ const target = event.target as HTMLElement | null
+ const typing =
+ target !== null &&
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
+ if (typing) return
+
+ if (event.key === 'j' || event.key === 'k') {
+ const targetId = event.key === 'j' ? position?.nextId : position?.prevId
+ if (targetId != null) {
+ event.preventDefault()
+ router.push(`/conversations/${targetId}`)
+ }
+ return
+ }
+ if (event.key === 'r') {
+ event.preventDefault()
+ openComposer('reply')
+ return
+ }
+ if (event.key === 'n') {
+ event.preventDefault()
+ openComposer('note')
+ }
+ }
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent): void {
+ onKeyDownRef.current(event)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [])
+
+ function changeStatus(next: ConversationStatus): void {
+ setStatusMenuOpen(false)
+ startTransition(async () => {
+ const result = await setStatusAction(conversation.id, next)
+ if (result.ok) {
+ setLocalStatus(next)
+ showToast({ title: `Marked ${next}` })
+ router.refresh()
+ } else {
+ // Surface the failure like tags/assignee do — never leave a status
+ // change silently dropped.
+ showToast({ title: "Couldn't update the conversation", detail: 'Please try again.' })
+ }
+ })
+ }
+
+ async function updateTags(nextTags: string[]): Promise {
+ const previous = tags
+ setTags(nextTags)
+ const result = await putTagsAction(conversation.id, nextTags)
+ if (!result.ok) {
+ setTags(previous)
+ showToast({ title: "Couldn't update the tags", detail: 'Please try again.' })
+ }
+ }
+
+ function addTagFromInput(): void {
+ const value = tagInput.trim().toLowerCase()
+ if (value.length === 0 || value.length > MAX_TAG_LENGTH || tags.includes(value)) return
+ setTagInput('')
+ void updateTags([...tags, value])
+ }
+
+ function onTagInputKeyDown(event: { key: string; preventDefault: () => void }): void {
+ if (event.key !== 'Enter') return
+ event.preventDefault()
+ addTagFromInput()
+ }
+
+ async function updateAssignee(next: 'me' | null): Promise {
+ const previous = assignee
+ setAssigneeMenuOpen(false)
+ setAssignee(next)
+ const result = await putAssigneeAction(conversation.id, next)
+ if (!result.ok) {
+ setAssignee(previous)
+ showToast({ title: "Couldn't update the assignee", detail: 'Please try again.' })
+ }
+ }
+
+ function onDeleteClick(): void {
+ if (!deleteArmed) {
+ setDeleteArmed(true)
+ deleteDisarmTimer.current = setTimeout(() => setDeleteArmed(false), DELETE_DISARM_MS)
+ return
+ }
+ if (deleteDisarmTimer.current !== null) clearTimeout(deleteDisarmTimer.current)
+ setDeleteArmed(false)
+ startTransition(async () => {
+ const result = await deleteConversationAction(conversation.id)
+ if (result.ok) {
+ showToast({ title: 'Conversation deleted' })
+ router.push('/inbox/unassigned')
+ }
+ })
+ }
+
+ function toggleFollowing(): void {
+ setMoreMenuOpen(false)
+ const next = !following
+ setFollowing(next)
+ showToast({ title: next ? 'Following conversation' : 'Unfollowed conversation' })
+ }
+
+ const customerName = nameFromEmail(conversation.customerEmail)
+ const starred = isStarred(conversation.id)
+
+ // sameSpeakerAsPrev compares CHRONOLOGICALLY adjacent messages — computed
+ // here, before the list is reversed for newest-first presentation. The
+ // API itself stays oldest-first (this is a presentation-layer reorder).
+ const threadsWithMeta = conversation.threads.map((thread, index) => {
+ const prev = conversation.threads[index - 1]
+ return {
+ thread,
+ sameSpeakerAsPrev: prev !== undefined && prev.direction === thread.direction,
+ }
+ })
+ const threadsNewestFirst = [...threadsWithMeta].reverse()
+
+ return (
+
+
+
+ openComposer('reply')}>
+
+
+ openComposer('note')}>
+
+
+
+ {deleteArmed ? (
+
+
+ Confirm
+
+ ) : (
+
+
+
+ )}
+
+
+
setTagsMenuOpen((open) => !open)}
+ >
+
+
+ {tags.length > 0 && (
+
+ {tags.length}
+
+ )}
+
setTagsMenuOpen(false)} minWidth={220}>
+
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+ void updateTags(tags.filter((t) => t !== tag))}
+ />
+ ))}
+
+ )}
+
+ setTagInput(event.target.value)
+ }
+ onKeyDown={onTagInputKeyDown}
+ placeholder="Add a tag…"
+ />
+
+
+
+
+ toggle(conversation.id)}
+ style={{ color: starred ? 'var(--ht-accent)' : 'var(--ht-ink-dim)' }}
+ >
+
+
+
+
+ setMoreMenuOpen((open) => !open)}
+ >
+
+
+ setMoreMenuOpen(false)} minWidth={190}>
+
+ {following ? 'Following ✓' : 'Follow'}
+
+ {
+ setMoreMenuOpen(false)
+ showToast({
+ title: "Forward isn't wired yet",
+ detail: 'Designed for v1 — arriving in a later increment.',
+ })
+ }}
+ >
+ Forward
+
+ {
+ setMoreMenuOpen(false)
+ showToast({
+ title: "Merge isn't wired yet",
+ detail: 'Designed for v1 — arriving in a later increment.',
+ })
+ }}
+ >
+ Merge
+
+ {
+ setMoreMenuOpen(false)
+ window.print()
+ }}
+ >
+ Print
+
+
+
+
+
+
+
+
setAssigneeMenuOpen((open) => !open)}>
+
+ {assignee === 'me' ? 'Me' : 'Anyone'}
+
+
+
setAssigneeMenuOpen(false)}
+ align="right"
+ >
+ void updateAssignee(null)}>
+ Anyone
+
+ void updateAssignee('me')}>
+ Me
+
+
+
+
+
+ setStatusMenuOpen((open) => !open)}
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 6,
+ border: 'none',
+ background: STATUS_META[status].bg,
+ borderRadius: 999,
+ padding: '6px 12px',
+ fontSize: 12.5,
+ fontWeight: 700,
+ color: STATUS_META[status].fg,
+ cursor: 'pointer',
+ }}
+ >
+
+ {STATUS_META[status].label}
+
+
+ setStatusMenuOpen(false)}
+ align="right"
+ >
+ changeStatus('active')}>
+ Active
+
+ changeStatus('pending')}>
+ Pending
+
+ changeStatus('closed')}>
+ Closed
+
+ changeStatus('spam')}
+ >
+ Spam
+
+
+
+
+ {position !== null && (
+
+
+ {position.index} of {position.total}
+
+ {
+ if (position.prevId !== null) router.push(`/conversations/${position.prevId}`)
+ }}
+ style={{
+ opacity: position.prevId !== null ? 1 : 0.35,
+ pointerEvents: position.prevId !== null ? 'auto' : 'none',
+ }}
+ >
+
+
+ {
+ if (position.nextId !== null) router.push(`/conversations/${position.nextId}`)
+ }}
+ style={{
+ opacity: position.nextId !== null ? 1 : 0.35,
+ pointerEvents: position.nextId !== null ? 'auto' : 'none',
+ }}
+ >
+
+
+
+ )}
+
+
+
+ {conversation.subject}
+
+
+
+ {composerOpen && (
+
+
+
+ switchComposerMode('reply')}
+ style={pillTabStyle(composerMode === 'reply')}
+ >
+ Reply
+
+ switchComposerMode('note')}
+ style={pillTabStyle(composerMode === 'note')}
+ >
+ Note
+
+
+
+ {/* Reply mode hosts the close button on the format-toolbar row
+ below (design: icons ... caption ... ×); Note mode has no
+ format row, so its close button stays up here. */}
+ {composerMode === 'note' && (
+
+
+
+ )}
+
+
+ {composerMode === 'note' && (
+
+ Internal note — visible to Agents only, never emailed to the customer.
+
+ )}
+ {composerMode === 'reply' && (status === 'closed' || status === 'spam') && (
+
+ This conversation is closed — sending a reply will reopen it.
+
+ )}
+ {composerMode === 'reply' && sendFailed && (
+
+
+ Your reply couldn't be delivered.
+
+
+ Nothing reached the customer. The draft is preserved.
+
+
+
+ {isPending ? 'Sending…' : 'Retry send'}
+
+
+
+ )}
+
+ {composerMode === 'reply' ? (
+ <>
+
+ applyFormat('bold')}
+ style={FORMAT_BUTTON_STYLE}
+ onMouseEnter={onFormatButtonHover}
+ onMouseLeave={onFormatButtonUnhover}
+ >
+ B
+
+ applyFormat('italic')}
+ style={FORMAT_BUTTON_STYLE}
+ onMouseEnter={onFormatButtonHover}
+ onMouseLeave={onFormatButtonUnhover}
+ >
+ I
+
+ applyFormat('insertUnorderedList')}
+ style={FORMAT_BUTTON_STYLE}
+ onMouseEnter={onFormatButtonHover}
+ onMouseLeave={onFormatButtonUnhover}
+ >
+
+
+
+
+
+
+
+ Formatting is sent as HTML alongside plain text
+
+
+
+
+
+
+ {replyTextLength === 0 && (
+
+ Reply to {customerName}…
+
+ )}
+ {/* biome-ignore lint/a11y/useSemanticElements: rich-text
+ formatting (bold/italic/list/link via execCommand)
+ requires a contenteditable element — a
+ >
+ ) : (
+
+ )}
+
+
+ {threadsNewestFirst.map(({ thread, sameSpeakerAsPrev }) => {
+ const kind = thread.direction
+ return (
+
+
+ {thread.bodyHtml !== null ? (
+ <>
+
+
+ HTML email · sanitized · external images blocked
+
+ >
+ ) : (
+ (thread.bodyText ?? '')
+ )}
+
+
+ setOpenMessageMenuId((current) => (current === thread.id ? null : thread.id))
+ }
+ onClose={() => setOpenMessageMenuId(null)}
+ onCopyText={() => {
+ setOpenMessageMenuId(null)
+ void navigator.clipboard.writeText(thread.bodyText ?? '')
+ showToast({ title: 'Message text copied' })
+ }}
+ onShowOriginal={() => {
+ setOpenMessageMenuId(null)
+ setOriginalMessage(thread)
+ }}
+ />
+
+ )
+ })}
+
+
+
+
+
+ {originalMessage !== null && (
+
setOriginalMessage(null)} />
+ )}
+
+ )
+}
diff --git a/web/src/components/DashboardScreen.tsx b/web/src/components/DashboardScreen.tsx
new file mode 100644
index 0000000..c4a6219
--- /dev/null
+++ b/web/src/components/DashboardScreen.tsx
@@ -0,0 +1,142 @@
+'use client'
+
+/**
+ * /dashboard — a centered mailbox card: the seven folders with their counts
+ * (Unassigned's shown as a filled accent badge, the others plain), and
+ * quick links into the inbox and settings. The wordmark and the top bar's
+ * Mailbox tab both land here. Counts use the same server-fetched +
+ * localStorage-merged mechanism as the folder rail (`lib/folder-counts.ts`,
+ * `mergeFolderCounts`).
+ */
+
+import Link from 'next/link'
+import { useDrafts } from '../lib/drafts'
+import {
+ FOLDER_ICON_PATHS,
+ FOLDER_LABELS,
+ FOLDER_ORDER,
+ mergeFolderCounts,
+ type ServerFolderCounts,
+} from '../lib/folders'
+import { useStarred } from '../lib/starred'
+
+function FolderIcon({ path }: { path: string }) {
+ return (
+
+
+
+ )
+}
+
+export function DashboardScreen({
+ supportAddress,
+ counts,
+}: {
+ supportAddress: string
+ counts: ServerFolderCounts
+}) {
+ const { starredIds } = useStarred()
+ const drafts = useDrafts()
+
+ const merged = mergeFolderCounts(counts, {
+ starred: starredIds.length,
+ drafts: Object.keys(drafts).length,
+ })
+
+ return (
+
+
+
+
+
Support
+
+ {supportAddress}
+
+
+
+
+ {FOLDER_ORDER.map((folder) => {
+ const count = merged[folder]
+ return (
+
+
+
+
+ {FOLDER_LABELS[folder]}
+ {count !== '' &&
+ (folder === 'unassigned' ? (
+
+ {count}
+
+ ) : (
+
+ {count}
+
+ ))}
+
+ )
+ })}
+
+
+
+
+ Open inbox →
+
+
+ Settings
+
+
+
+
+
+ )
+}
diff --git a/web/src/components/FolderNav.tsx b/web/src/components/FolderNav.tsx
new file mode 100644
index 0000000..53be656
--- /dev/null
+++ b/web/src/components/FolderNav.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+/**
+ * The persistent folder sidebar — part of the app SHELL (the `(shell)`
+ * layout), so it stays put whether you're reading the list or inside a
+ * conversation, per the design's anatomy. The active folder is derived from
+ * the URL; inside a conversation no folder is active, but the rail remains.
+ *
+ * Counts: the five API-backed folders arrive as server-fetched props
+ * (`(shell)/layout.tsx`, via `lib/folder-counts.ts`); Starred and Drafts are
+ * localStorage-only and merged in here client-side (`mergeFolderCounts`).
+ */
+
+import { usePathname, useRouter } from 'next/navigation'
+import { useDrafts } from '../lib/drafts'
+import {
+ FOLDER_ICON_PATHS,
+ FOLDER_LABELS,
+ FOLDER_ORDER,
+ mergeFolderCounts,
+ type ServerFolderCounts,
+} from '../lib/folders'
+import { useStarred } from '../lib/starred'
+import { FolderItem } from './ds/inbox/FolderItem'
+import { useToast } from './Toaster'
+
+function FolderIcon({ path }: { path: string }) {
+ return (
+
+
+
+ )
+}
+
+function SettingsIcon() {
+ return (
+
+
+
+ )
+}
+
+function MailIcon() {
+ return (
+
+
+
+ )
+}
+
+export function FolderNav({
+ supportAddress,
+ counts,
+}: {
+ supportAddress: string
+ counts: ServerFolderCounts
+}) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const showToast = useToast()
+ const { starredIds } = useStarred()
+ const drafts = useDrafts()
+
+ const merged = mergeFolderCounts(counts, {
+ starred: starredIds.length,
+ drafts: Object.keys(drafts).length,
+ })
+
+ return (
+
+
+
Support
+
+ {supportAddress}
+
+
+
+
+ {FOLDER_ORDER.map((folder) => (
+ }
+ label={FOLDER_LABELS[folder]}
+ count={merged[folder]}
+ active={pathname === `/inbox/${folder}`}
+ hasItems={merged[folder] !== ''}
+ onClick={() => router.push(`/inbox/${folder}`)}
+ />
+ ))}
+
+
+
+ router.push('/settings')}
+ style={{
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: 'none',
+ borderRight: '1px solid var(--ht-border)',
+ background: 'none',
+ padding: '9px 0',
+ color: 'var(--ht-ink-muted)',
+ cursor: 'pointer',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.background = 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'none'
+ }}
+ >
+
+
+
+ showToast({
+ title: "New message isn't wired yet",
+ detail: "Designed for v1 — the outbound-new endpoint is spec'd, not in the mock.",
+ })
+ }
+ style={{
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ border: 'none',
+ background: 'none',
+ padding: '9px 0',
+ color: 'var(--ht-ink-muted)',
+ cursor: 'pointer',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.background = 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'none'
+ }}
+ >
+
+
+
+
+ )
+}
diff --git a/web/src/components/InboxScreen.tsx b/web/src/components/InboxScreen.tsx
new file mode 100644
index 0000000..67d347f
--- /dev/null
+++ b/web/src/components/InboxScreen.tsx
@@ -0,0 +1,419 @@
+'use client'
+
+/**
+ * The inbox list — the elevated work surface beside the shell's persistent
+ * folder rail (`FolderNav`, rendered by the `(shell)` layout). Composed from
+ * the design system's own components. The server page hands down whichever
+ * flat conversation list the folder needs (see `app/(shell)/inbox/[folder]`);
+ * Unassigned/Mine/Assigned/Starred/Drafts filter that list client-side
+ * (assignee for the first three, localStorage for the last two) — Closed
+ * and Spam show it as-is, with in-place keyset pagination (`loadOlderAction`
+ * appends pages to client state; no route navigation).
+ *
+ * The header band doubles as a bulk-selection bar: with 0 rows checked it
+ * shows the column labels and the sort toggle; with 1+ checked it swaps to
+ * the selection count and the bulk actions (status, delete).
+ */
+
+import { useRouter } from 'next/navigation'
+import type { CSSProperties } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { deleteConversationAction, loadOlderAction, setStatusAction } from '../lib/actions'
+import type { ConversationStatus, ConversationSummary } from '../lib/api-types'
+import { useDrafts } from '../lib/drafts'
+import { type AppFolder, EMPTY_COPY } from '../lib/folders'
+import { nameFromEmail, relativeTime } from '../lib/format'
+import { useStarred } from '../lib/starred'
+import { Button } from './ds/core/Button'
+import { DropdownMenu } from './ds/core/DropdownMenu'
+import { EmptyState } from './ds/core/EmptyState'
+import { Kbd } from './ds/core/Kbd'
+import { MenuItem } from './ds/core/MenuItem'
+import { StatusPill } from './ds/core/StatusPill'
+import { ConversationRow } from './ds/inbox/ConversationRow'
+import { ToolbarBand } from './ds/inbox/ToolbarBand'
+import { useShortcutsOverlay } from './ShortcutsProvider'
+import { useToast } from './Toaster'
+
+const DELETE_DISARM_MS = 3500
+
+const HEADER_LABEL_STYLE: CSSProperties = {
+ fontSize: 10.5,
+ fontWeight: 700,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ color: 'var(--ht-ink-dim)',
+}
+
+function pluralize(count: number, noun: string): string {
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
+}
+
+function ChevronDownIcon() {
+ return (
+
+
+
+ )
+}
+
+export function InboxScreen({
+ folder,
+ conversations,
+ nextCursor,
+}: {
+ folder: AppFolder
+ conversations: ConversationSummary[]
+ nextCursor: string | null
+}) {
+ const router = useRouter()
+ const showToast = useToast()
+ const { isStarred, toggle } = useStarred()
+ const drafts = useDrafts()
+ const { isOpen: isShortcutsOverlayOpen } = useShortcutsOverlay()
+
+ const [extraPages, setExtraPages] = useState([])
+ const [cursor, setCursor] = useState(nextCursor)
+ const [loadingMore, setLoadingMore] = useState(false)
+
+ const [selected, setSelected] = useState>(new Set())
+ const [sortDesc, setSortDesc] = useState(true)
+ const [statusMenuOpen, setStatusMenuOpen] = useState(false)
+ const [deleteArmed, setDeleteArmed] = useState(false)
+ const disarmTimer = useRef | null>(null)
+
+ // j/k (and ↑/↓) move this cursor; Enter opens it, x toggles its checkbox.
+ // Starts at -1 (NO row focused) so the table shows no highlight on load —
+ // the design has no default-selected row; the cursor appears only once the
+ // Agent starts navigating (the first j/k/arrow lands on row 0).
+ const [focusedIndex, setFocusedIndex] = useState(-1)
+ const rowRefs = useRef>(new Map())
+
+ // A folder switch is a fresh list — appended pages, selection, and the
+ // armed-delete state from the previous folder don't carry over. `folder`
+ // is the intended trigger (a route change); `nextCursor` always changes
+ // alongside it (it's that folder's freshly server-fetched first page).
+ // biome-ignore lint/correctness/useExhaustiveDependencies: folder is the trigger, not read in the body
+ useEffect(() => {
+ setExtraPages([])
+ setCursor(nextCursor)
+ setSelected(new Set())
+ setDeleteArmed(false)
+ setFocusedIndex(-1)
+ if (disarmTimer.current !== null) clearTimeout(disarmTimer.current)
+ }, [folder, nextCursor])
+
+ useEffect(() => {
+ return () => {
+ if (disarmTimer.current !== null) clearTimeout(disarmTimer.current)
+ }
+ }, [])
+
+ const showCheckboxColumn = folder !== 'starred' && folder !== 'drafts'
+
+ const visible = [...conversations, ...extraPages].filter((c) => {
+ if (folder === 'unassigned') return c.assignee === null
+ if (folder === 'mine') return c.assignee === 'me'
+ if (folder === 'assigned') return c.assignee !== null
+ if (folder === 'starred') return isStarred(c.id)
+ if (folder === 'drafts') return c.id in drafts
+ return true // closed | spam: shown as fetched
+ })
+ const displayed = sortDesc ? visible : [...visible].reverse()
+ const allChecked = visible.length > 0 && visible.every((c) => selected.has(c.id))
+
+ // The list can shrink (unstarring the focused row while on Starred, etc.)
+ // without a folder switch — keep the cursor in bounds.
+ useEffect(() => {
+ setFocusedIndex((i) => Math.min(i, Math.max(displayed.length - 1, 0)))
+ }, [displayed.length])
+
+ useEffect(() => {
+ const row = displayed[focusedIndex]
+ if (row === undefined) return
+ rowRefs.current.get(row.id)?.scrollIntoView({ block: 'nearest' })
+ }, [focusedIndex, displayed])
+
+ // Kept in a ref (the "latest closure" pattern): `displayed` is a fresh
+ // array every render, so listing it as a dependency would churn the DOM
+ // listener on every render — only the closure it calls needs to be fresh.
+ const onKeyDownRef = useRef<(event: KeyboardEvent) => void>(() => {})
+ onKeyDownRef.current = (event: KeyboardEvent) => {
+ // The shortcuts overlay owns Escape/'?' while open; don't also act.
+ if (isShortcutsOverlayOpen) return
+
+ const target = event.target as HTMLElement | null
+ const typing =
+ target !== null &&
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
+ if (typing) return
+
+ if (event.key === 'j' || event.key === 'ArrowDown') {
+ event.preventDefault()
+ setFocusedIndex((i) => Math.min(i + 1, Math.max(displayed.length - 1, 0)))
+ return
+ }
+ if (event.key === 'k' || event.key === 'ArrowUp') {
+ event.preventDefault()
+ setFocusedIndex((i) => Math.max(i - 1, 0))
+ return
+ }
+ if (event.key === 'Enter') {
+ const row = displayed[focusedIndex]
+ if (row !== undefined) router.push(`/conversations/${row.id}`)
+ return
+ }
+ if (event.key === 'x' && showCheckboxColumn) {
+ const row = displayed[focusedIndex]
+ if (row !== undefined) toggleOne(row.id)
+ }
+ }
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent): void {
+ onKeyDownRef.current(event)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [])
+
+ function toggleAll(): void {
+ setSelected(allChecked ? new Set() : new Set(visible.map((c) => c.id)))
+ }
+
+ function toggleOne(id: string): void {
+ setSelected((current) => {
+ const next = new Set(current)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ function clearSelection(): void {
+ setSelected(new Set())
+ if (disarmTimer.current !== null) clearTimeout(disarmTimer.current)
+ setDeleteArmed(false)
+ }
+
+ async function applyStatus(status: ConversationStatus): Promise {
+ setStatusMenuOpen(false)
+ const ids = Array.from(selected)
+ let successCount = 0
+ let hadFailure = false
+ for (const id of ids) {
+ const result = await setStatusAction(id, status)
+ if (result.ok) successCount++
+ else hadFailure = true
+ }
+ if (hadFailure) {
+ showToast({ title: "Couldn't update the conversation", detail: 'Please try again.' })
+ }
+ if (successCount > 0) {
+ showToast({ title: `${pluralize(successCount, 'conversation')} marked ${status}` })
+ }
+ setSelected(new Set())
+ router.refresh()
+ }
+
+ function onDeleteClick(): void {
+ if (!deleteArmed) {
+ setDeleteArmed(true)
+ disarmTimer.current = setTimeout(() => setDeleteArmed(false), DELETE_DISARM_MS)
+ return
+ }
+ if (disarmTimer.current !== null) clearTimeout(disarmTimer.current)
+ setDeleteArmed(false)
+ void performDelete()
+ }
+
+ async function performDelete(): Promise {
+ const ids = Array.from(selected)
+ let successCount = 0
+ let hadFailure = false
+ for (const id of ids) {
+ const result = await deleteConversationAction(id)
+ if (result.ok) successCount++
+ else hadFailure = true
+ }
+ if (hadFailure) {
+ showToast({ title: "Couldn't update the conversation", detail: 'Please try again.' })
+ }
+ if (successCount > 0) {
+ showToast({ title: `${pluralize(successCount, 'conversation')} deleted` })
+ }
+ setSelected(new Set())
+ router.refresh()
+ }
+
+ async function loadOlder(): Promise {
+ if (cursor === null || loadingMore) return
+ setLoadingMore(true)
+ try {
+ // Only reachable for closed/spam (the only folders the server ever
+ // hands a non-null cursor for — see `app/(shell)/inbox/[folder]`).
+ const page = await loadOlderAction(folder as 'closed' | 'spam', cursor)
+ setExtraPages((current) => [...current, ...page.conversations])
+ setCursor(page.nextCursor)
+ } catch {
+ showToast({ title: "Couldn't load older conversations", detail: 'Please try again.' })
+ } finally {
+ setLoadingMore(false)
+ }
+ }
+
+ return (
+
+
+
+ {selected.size > 0 ? (
+ <>
+
{selected.size} selected
+
+ setStatusMenuOpen((open) => !open)}>
+ Set status
+
+
+ setStatusMenuOpen(false)}>
+ applyStatus('active')}>Active
+ applyStatus('pending')}>Pending
+ applyStatus('closed')}>Closed
+ applyStatus('spam')}>
+ Spam
+
+
+
+
+ {deleteArmed ? 'Confirm delete' : 'Delete'}
+
+
+ Clear
+
+ >
+ ) : (
+ <>
+ {showCheckboxColumn && (
+
+ )}
+
Customer
+
+
Conversation
+
+
+ Number
+
+
setSortDesc((desc) => !desc)}
+ style={{
+ minWidth: 96,
+ textAlign: 'right',
+ background: 'none',
+ border: 'none',
+ cursor: 'pointer',
+ fontSize: 12,
+ fontWeight: 600,
+ color: 'var(--ht-accent)',
+ padding: 0,
+ }}
+ >
+ Waiting since {sortDesc ? '↓' : '↑'}
+
+ >
+ )}
+
+
+
+ {displayed.length === 0 ? (
+
+ ) : (
+
+ {displayed.map((c, index) => (
+
{
+ if (el) rowRefs.current.set(c.id, el)
+ else rowRefs.current.delete(c.id)
+ }}
+ style={{ position: 'relative' }}
+ >
+ 1 ? String(c.threadCount) : ''}
+ number={String(c.number)}
+ time={relativeTime(c.updatedAt)}
+ showCheckbox={showCheckboxColumn}
+ checked={selected.has(c.id)}
+ onCheck={() => toggleOne(c.id)}
+ starred={isStarred(c.id)}
+ onStar={() => toggle(c.id)}
+ selected={index === focusedIndex}
+ onClick={() => {
+ setFocusedIndex(index)
+ router.push(`/conversations/${c.id}`)
+ }}
+ />
+ {c.status === 'pending' && (
+
+
+
+ )}
+
+ ))}
+ {cursor !== null && (
+
+
+ {loadingMore ? 'Loading…' : 'Load older conversations'}
+
+
+ )}
+
+ )}
+
+
+ j k navigate · ↵ open · ? shortcuts
+
+
+ )
+}
diff --git a/web/src/components/SanitizedHtml.tsx b/web/src/components/SanitizedHtml.tsx
new file mode 100644
index 0000000..533e76a
--- /dev/null
+++ b/web/src/components/SanitizedHtml.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+/**
+ * The ONE place inbound email HTML is rendered (spec §5: `bodyHtml` is
+ * untrusted, unsanitized, attacker-controlled — a stored-XSS vector against
+ * the Agent). Every byte passes through DOMPurify in the browser before it
+ * touches the DOM, inside a container that constrains layout blowouts.
+ * Remote images are stripped for now (a click-to-load affordance is a later
+ * increment) — an inbound ` ` is also a tracking pixel aimed at the
+ * Agent.
+ */
+
+import DOMPurify from 'dompurify'
+import { useMemo } from 'react'
+
+export function SanitizedHtml({ html }: { html: string }) {
+ const clean = useMemo(
+ () =>
+ DOMPurify.sanitize(html, {
+ USE_PROFILES: { html: true },
+ FORBID_TAGS: ['img', 'style', 'form', 'input', 'button'],
+ FORBID_ATTR: ['style'],
+ }),
+ [html],
+ )
+
+ return (
+
+ )
+}
diff --git a/web/src/components/SettingsScreen.tsx b/web/src/components/SettingsScreen.tsx
new file mode 100644
index 0000000..dec6dc0
--- /dev/null
+++ b/web/src/components/SettingsScreen.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+/**
+ * /settings — three cards on the paper background, deliberately outside the
+ * `(shell)` group (no folder rail). The Deployment card is plain read-only
+ * data handed down from the server page; Appearance is the one interactive
+ * bit here, wired to `useTheme`.
+ */
+
+import Link from 'next/link'
+import type { ReactNode } from 'react'
+import type { Theme } from '../lib/theme'
+import { useTheme } from './ThemeProvider'
+
+export interface DeploymentInfo {
+ productName: string
+ supportAddress: string
+ mailDomain: string
+}
+
+const THEME_OPTIONS: Array<{ value: Theme; label: string }> = [
+ { value: 'light', label: 'Light' },
+ { value: 'dark', label: 'Dark' },
+ { value: 'system', label: 'System' },
+]
+
+function Card({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ )
+}
+
+export function SettingsScreen({ deployment }: { deployment: DeploymentInfo }) {
+ const { theme, setTheme } = useTheme()
+
+ return (
+
+
+
+ ← Inbox
+
+
+
+
+ Product name
+ {deployment.productName}
+ Support address
+ {deployment.supportAddress}
+ Mail domain
+ {deployment.mailDomain}
+
+
+
+
+
+ {THEME_OPTIONS.map((option) => {
+ const selected = theme === option.value
+ return (
+ setTheme(option.value)}
+ style={{
+ border: 'none',
+ padding: '7px 16px',
+ fontSize: 13,
+ fontWeight: 600,
+ cursor: 'pointer',
+ color: selected ? 'var(--ht-on-accent)' : 'var(--ht-ink-muted)',
+ background: selected ? 'var(--ht-accent)' : 'transparent',
+ }}
+ >
+ {option.label}
+
+ )
+ })}
+
+
+
+
+
+ Rebranding Helpthread is one edit: the accent and neutrals in{' '}
+ theme/helpthread.css. The product
+ name, support address, and mail domain come from the deployment's identity
+ configuration, set once at deploy time.
+
+
+
+
+ )
+}
diff --git a/web/src/components/ShortcutsOverlay.tsx b/web/src/components/ShortcutsOverlay.tsx
new file mode 100644
index 0000000..ef53152
--- /dev/null
+++ b/web/src/components/ShortcutsOverlay.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+/** The keyboard-shortcuts modal (fidelity checklist). Toggled/closed by `ShortcutsProvider`. */
+
+import { type ReactNode, useEffect, useRef } from 'react'
+import { Kbd } from './ds/core/Kbd'
+
+const ROWS: Array<{ keys: ReactNode; label: string }> = [
+ {
+ keys: (
+ <>
+ j
+ k
+ >
+ ),
+ label: 'Move through the inbox',
+ },
+ { keys: ↵ , label: 'Open the selected conversation' },
+ { keys: x , label: 'Select the focused conversation' },
+ {
+ keys: (
+ <>
+ j
+ k
+ >
+ ),
+ label: 'Next / previous conversation (while reading)',
+ },
+ { keys: r , label: 'Open the reply composer' },
+ { keys: n , label: 'Add an internal note' },
+ {
+ keys: (
+ <>
+ ⌘/Ctrl
+ ↵
+ >
+ ),
+ label: 'Send the reply',
+ },
+ { keys: Esc , label: 'Back to the inbox / close dialogs' },
+ { keys: ? , label: 'Show this overlay' },
+]
+
+export function ShortcutsOverlay({ onClose }: { onClose: () => void }) {
+ const closeRef = useRef(null)
+
+ // Focus the close button on open and return focus to whatever was focused
+ // before, on close — the baseline modal a11y contract. (Esc is handled by
+ // ShortcutsProvider.) The close button is the dialog's only focusable
+ // control, so the Tab handler below keeps focus on it — a minimal trap.
+ useEffect(() => {
+ const previous = document.activeElement as HTMLElement | null
+ closeRef.current?.focus()
+ return () => previous?.focus?.()
+ }, [])
+
+ return (
+ // Backdrop is non-interactive by design — Esc and the header's close
+ // chip are the documented ways to dismiss (see ShortcutsProvider).
+ {
+ if (event.key === 'Tab') {
+ // One focusable control — keep focus trapped on it.
+ event.preventDefault()
+ closeRef.current?.focus()
+ }
+ }}
+ style={{
+ position: 'fixed',
+ inset: 0,
+ background: 'oklch(0 0 0 / 0.4)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ zIndex: 80,
+ }}
+ >
+
+
+ Keyboard shortcuts
+
+ Esc
+
+
+
+ {ROWS.map((row) => (
+
+ {row.label}
+ {row.keys}
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/src/components/ShortcutsProvider.tsx b/web/src/components/ShortcutsProvider.tsx
new file mode 100644
index 0000000..e0b5ff5
--- /dev/null
+++ b/web/src/components/ShortcutsProvider.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+/**
+ * Global keyboard-shortcut wiring, mounted once in `app/layout.tsx`.
+ *
+ * `?` toggles the shortcuts overlay, `Escape` closes it. The overlay can
+ * also be opened imperatively — `useShortcutsOverlay().open()` — for the
+ * top bar's "Keyboard shortcuts" menu item.
+ *
+ * `isOpen` is exposed too: the inbox and conversation screens run their OWN
+ * key handling (j/k, r/n, ⌘+↵, a cascading Escape — see `InboxScreen` and
+ * `ConversationScreen`), and this Escape listener doesn't stop propagation,
+ * so a screen's own handler would otherwise ALSO react to the same Escape
+ * press that closes the overlay. Screens check `isOpen` first and skip
+ * their own handling while the overlay is open.
+ */
+
+import type { ReactNode } from 'react'
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { ShortcutsOverlay } from './ShortcutsOverlay'
+
+interface ShortcutsOverlayContextValue {
+ open: () => void
+ isOpen: boolean
+}
+
+const ShortcutsOverlayContext = createContext(null)
+
+export function ShortcutsProvider({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent): void {
+ if (event.key === 'Escape') {
+ setOpen(false)
+ return
+ }
+
+ if (event.key !== '?') return
+
+ const target = event.target as HTMLElement | null
+ const typing =
+ target !== null &&
+ (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
+ if (typing) return
+
+ event.preventDefault()
+ setOpen((current) => !current)
+ }
+
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [])
+
+ const openOverlay = useCallback(() => setOpen(true), [])
+ const value = useMemo(() => ({ open: openOverlay, isOpen: open }), [openOverlay, open])
+
+ return (
+
+ {children}
+ {open && setOpen(false)} />}
+
+ )
+}
+
+/** `const { open, isOpen } = useShortcutsOverlay()`. */
+export function useShortcutsOverlay(): ShortcutsOverlayContextValue {
+ const ctx = useContext(ShortcutsOverlayContext)
+ if (ctx === null) throw new Error('useShortcutsOverlay must be used within ShortcutsProvider')
+ return ctx
+}
diff --git a/web/src/components/ThemeProvider.tsx b/web/src/components/ThemeProvider.tsx
new file mode 100644
index 0000000..67e59de
--- /dev/null
+++ b/web/src/components/ThemeProvider.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+/**
+ * Theme state — 'light' | 'dark' | 'system' — persisted in
+ * `localStorage[THEME_STORAGE_KEY]`. The DOM attribute is set synchronously
+ * pre-hydration by the inline script in `app/layout.tsx` (see
+ * `lib/theme.ts`'s `THEME_INIT_SCRIPT`, which avoids a flash of the wrong
+ * theme); this provider only reads the saved choice AFTER mount — never
+ * during the initial render — so the server-rendered HTML and the client's
+ * first render always agree (no hydration mismatch), then keeps the
+ * attribute in sync afterward, including 'system' tracking
+ * `prefers-color-scheme` live.
+ */
+
+import type { ReactNode } from 'react'
+import { createContext, useCallback, useContext, useEffect, useState } from 'react'
+import { resolveIsDark, THEME_STORAGE_KEY, type Theme } from '../lib/theme'
+
+export type { Theme }
+
+function applyTheme(theme: Theme): void {
+ const root = document.documentElement
+ if (resolveIsDark(theme)) {
+ root.setAttribute('data-theme', 'dark')
+ } else {
+ root.removeAttribute('data-theme')
+ }
+}
+
+const ThemeContext = createContext<{ theme: Theme; setTheme: (next: Theme) => void } | null>(null)
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [theme, setThemeState] = useState('system')
+
+ // Adopt the persisted choice once mounted (see the hydration note above).
+ useEffect(() => {
+ const stored = window.localStorage.getItem(THEME_STORAGE_KEY)
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
+ setThemeState(stored)
+ }
+ }, [])
+
+ useEffect(() => {
+ applyTheme(theme)
+ if (theme !== 'system') return
+ const mql = window.matchMedia('(prefers-color-scheme: dark)')
+ const listener = () => applyTheme('system')
+ mql.addEventListener('change', listener)
+ return () => mql.removeEventListener('change', listener)
+ }, [theme])
+
+ const setTheme = useCallback((next: Theme) => {
+ setThemeState(next)
+ window.localStorage.setItem(THEME_STORAGE_KEY, next)
+ }, [])
+
+ return {children}
+}
+
+export function useTheme(): { theme: Theme; setTheme: (next: Theme) => void } {
+ const ctx = useContext(ThemeContext)
+ if (ctx === null) throw new Error('useTheme must be used within ThemeProvider')
+ return ctx
+}
diff --git a/web/src/components/Toaster.tsx b/web/src/components/Toaster.tsx
new file mode 100644
index 0000000..6a85163
--- /dev/null
+++ b/web/src/components/Toaster.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+/**
+ * Toast stack, bottom-right. The DS `Toast` (`ds/core/Toast`) only knows
+ * how to render ONE toast (optionally self-fixed); this provider owns the
+ * stack, the fixed container, and the auto-dismiss timer, rendering each
+ * entry with `fixed` off so multiple can stack in our own container.
+ */
+
+import type { ReactNode } from 'react'
+import { createContext, useCallback, useContext, useRef, useState } from 'react'
+import { Toast } from './ds/core/Toast'
+
+export interface ShowToastInput {
+ title: string
+ detail?: string
+}
+
+interface ToastEntry extends ShowToastInput {
+ id: number
+}
+
+const AUTO_DISMISS_MS = 4200
+
+const ToastContext = createContext<((input: ShowToastInput) => void) | null>(null)
+
+export function ToasterProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([])
+ const nextId = useRef(0)
+
+ const showToast = useCallback(({ title, detail }: ShowToastInput) => {
+ const id = nextId.current++
+ setToasts((current) => [...current, { id, title, detail }])
+ setTimeout(() => {
+ setToasts((current) => current.filter((entry) => entry.id !== id))
+ }, AUTO_DISMISS_MS)
+ }, [])
+
+ return (
+
+ {children}
+
+ {toasts.map((toast) => (
+
+ ))}
+
+
+ )
+}
+
+/** `const showToast = useToast(); showToast({ title, detail? })`. */
+export function useToast(): (input: ShowToastInput) => void {
+ const ctx = useContext(ToastContext)
+ if (ctx === null) throw new Error('useToast must be used within ToasterProvider')
+ return ctx
+}
diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx
new file mode 100644
index 0000000..a99257a
--- /dev/null
+++ b/web/src/components/TopBar.tsx
@@ -0,0 +1,344 @@
+'use client'
+
+/**
+ * The app shell's top bar — the accent-filled surface, the design system's
+ * ONE colored surface. The wordmark and folder rail are the persistent
+ * anchors; everything on the right (Manage, Notifications, the Agent
+ * avatar) is a dropdown menu, and only one is open at a time.
+ *
+ * Notifications are read-only display: the 6 most recent OPEN conversations,
+ * fetched server-side in `app/layout.tsx` and handed down as props (no
+ * client-side polling, no unread state — that's not in the API yet).
+ */
+
+import Link from 'next/link'
+import { usePathname, useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import type { ConversationSummary } from '../lib/api-types'
+import { nameFromEmail, relativeTime } from '../lib/format'
+import { Avatar } from './ds/core/Avatar'
+import { DropdownMenu } from './ds/core/DropdownMenu'
+import { EmptyState } from './ds/core/EmptyState'
+import { IconButton } from './ds/core/IconButton'
+import { Kbd } from './ds/core/Kbd'
+import { MenuItem } from './ds/core/MenuItem'
+import { useShortcutsOverlay } from './ShortcutsProvider'
+import { useToast } from './Toaster'
+
+type MenuKey = 'manage' | 'notifications' | 'avatar'
+
+function ChevronDownIcon() {
+ return (
+
+
+
+ )
+}
+
+function BellIcon() {
+ return (
+
+
+
+ )
+}
+
+function NotificationRow({
+ conversation,
+ onNavigate,
+}: {
+ conversation: ConversationSummary
+ onNavigate: () => void
+}) {
+ return (
+ {
+ e.currentTarget.style.background = 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'transparent'
+ }}
+ >
+
+
+
+ {nameFromEmail(conversation.customerEmail)} — conversation #{conversation.number}
+
+
+ {conversation.preview}
+
+
+
+ {relativeTime(conversation.updatedAt)}
+
+
+ )
+}
+
+export function TopBar({ recentOpen }: { recentOpen: ConversationSummary[] }) {
+ const pathname = usePathname()
+ const router = useRouter()
+ const showToast = useToast()
+ const { open: openShortcuts } = useShortcutsOverlay()
+ const [openMenu, setOpenMenu] = useState(null)
+
+ useEffect(() => {
+ if (openMenu === null) return
+ function onKeyDown(event: KeyboardEvent): void {
+ if (event.key === 'Escape') setOpenMenu(null)
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [openMenu])
+
+ function toggle(key: MenuKey): void {
+ setOpenMenu((current) => (current === key ? null : key))
+ }
+
+ function stubToast(feature: string): void {
+ showToast({
+ title: `${feature} isn't wired yet`,
+ detail: "Designed for v1 — the endpoint is spec'd, not wired.",
+ })
+ }
+
+ const mailboxActive = pathname.startsWith('/inbox') || pathname.startsWith('/conversations')
+
+ return (
+
+
+ helpthread.
+
+
+
+
+ router.push('/inbox/open')}
+ style={{
+ border: 'none',
+ cursor: 'pointer',
+ fontSize: 13,
+ fontWeight: 600,
+ color: 'inherit',
+ padding: '6px 12px',
+ borderRadius: 'var(--ht-radius-md)',
+ background: mailboxActive
+ ? 'color-mix(in oklab, var(--ht-header-fg) 16%, transparent)'
+ : 'none',
+ }}
+ >
+ Mailbox
+
+
+
+ toggle('manage')}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 4,
+ border: 'none',
+ cursor: 'pointer',
+ fontSize: 13,
+ fontWeight: 600,
+ color: 'inherit',
+ padding: '6px 10px',
+ borderRadius: 'var(--ht-radius-md)',
+ background:
+ openMenu === 'manage'
+ ? 'color-mix(in oklab, var(--ht-header-fg) 16%, transparent)'
+ : 'none',
+ }}
+ >
+ Manage
+
+
+ setOpenMenu(null)}>
+ {
+ setOpenMenu(null)
+ router.push('/settings')
+ }}
+ >
+ Settings
+
+ ?}
+ onClick={() => {
+ setOpenMenu(null)
+ openShortcuts()
+ }}
+ >
+ Keyboard shortcuts
+
+
+
+
+
+
+
+
toggle('notifications')}
+ >
+
+
+
setOpenMenu(null)}
+ align="right"
+ minWidth={360}
+ >
+
+ Notifications
+
+ {recentOpen.length === 0 ? (
+
+
+
+ ) : (
+ recentOpen.map((conversation) => (
+ {
+ setOpenMenu(null)
+ router.push(`/conversations/${conversation.id}`)
+ }}
+ />
+ ))
+ )}
+
+
+
+
+
toggle('avatar')}
+ style={{
+ border: 'none',
+ background: 'none',
+ padding: 0,
+ display: 'flex',
+ cursor: 'pointer',
+ }}
+ >
+
+
+
setOpenMenu(null)} align="right">
+ {
+ setOpenMenu(null)
+ stubToast('Your profile')
+ }}
+ >
+ Your profile
+
+ {
+ setOpenMenu(null)
+ router.push('/settings')
+ }}
+ >
+ Settings
+
+ ?}
+ onClick={() => {
+ setOpenMenu(null)
+ openShortcuts()
+ }}
+ >
+ Keyboard shortcuts
+
+
+ {
+ setOpenMenu(null)
+ showToast({
+ title: 'No session to log out of',
+ detail:
+ 'v1 authenticates the deployment, not a user — sessions arrive with multi-Agent.',
+ })
+ }}
+ >
+ Log out
+
+
+
+
+ )
+}
diff --git a/web/src/components/ds/core/Avatar.d.ts b/web/src/components/ds/core/Avatar.d.ts
new file mode 100644
index 0000000..b52c00e
--- /dev/null
+++ b/web/src/components/ds/core/Avatar.d.ts
@@ -0,0 +1,13 @@
+export interface AvatarProps {
+ /** initials + tone derive from this */
+ email?: string
+ /** explicit initials override */
+ initials?: string
+ size?: number
+ /** the Agent avatar: accent fill, "S" */
+ agent?: boolean
+ /** white ring + shadow (threads, context panel) */
+ ring?: boolean
+ style?: React.CSSProperties
+}
+export declare function Avatar(props: AvatarProps): JSX.Element
diff --git a/web/src/components/ds/core/Avatar.jsx b/web/src/components/ds/core/Avatar.jsx
new file mode 100644
index 0000000..7b1bf01
--- /dev/null
+++ b/web/src/components/ds/core/Avatar.jsx
@@ -0,0 +1,46 @@
+import React from 'react'
+
+const PALETTE = [
+ ['oklch(0.9 0.03 60)', 'oklch(0.42 0.07 60)'],
+ ['oklch(0.9 0.03 150)', 'oklch(0.4 0.06 150)'],
+ ['oklch(0.9 0.03 250)', 'oklch(0.42 0.06 250)'],
+ ['oklch(0.9 0.03 320)', 'oklch(0.42 0.06 320)'],
+ ['oklch(0.9 0.035 95)', 'oklch(0.42 0.06 95)'],
+]
+function initialsOf(email) {
+ const local = String(email || '').split('@')[0]
+ const parts = local.split(/[._-]+/).filter(Boolean)
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
+ return local.slice(0, 2).toUpperCase()
+}
+
+/** Initials avatar. Tone is a stable hash of the email; agent=true fills with accent.
+ * ring adds the white ring + shadow used in threads and the context panel. */
+export function Avatar({ email, initials, size = 32, agent = false, ring = true, style }) {
+ let h = 0
+ for (const ch of String(email || initials || '?')) h = (h * 31 + ch.charCodeAt(0)) >>> 0
+ const [bg, fg] = agent ? ['var(--ht-accent)', 'var(--ht-on-accent)'] : PALETTE[h % PALETTE.length]
+ return (
+ = 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none',
+ boxShadow: ring ? (size >= 48 ? 'var(--ht-shadow-md)' : 'var(--ht-shadow-sm)') : 'none',
+ ...style,
+ }}
+ >
+ {initials || (agent ? 'S' : initialsOf(email))}
+
+ )
+}
diff --git a/web/src/components/ds/core/Button.d.ts b/web/src/components/ds/core/Button.d.ts
new file mode 100644
index 0000000..278e31e
--- /dev/null
+++ b/web/src/components/ds/core/Button.d.ts
@@ -0,0 +1,13 @@
+/** The brand's one button. Sentence case labels; destructive actions arm on first press instead of opening a modal. */
+export interface ButtonProps {
+ /** primary | outline | ghost | destructive */
+ variant?: 'primary' | 'outline' | 'ghost' | 'destructive'
+ /** destructive two-step arm state ("Confirm" fill) */
+ armed?: boolean
+ disabled?: boolean
+ title?: string
+ onClick?: () => void
+ style?: React.CSSProperties
+ children: React.ReactNode
+}
+export declare function Button(props: ButtonProps): JSX.Element
diff --git a/web/src/components/ds/core/Button.jsx b/web/src/components/ds/core/Button.jsx
new file mode 100644
index 0000000..580fa08
--- /dev/null
+++ b/web/src/components/ds/core/Button.jsx
@@ -0,0 +1,72 @@
+import React from 'react'
+
+/** Primary action button. variant: primary | outline | ghost | destructive.
+ * Destructive supports the two-step arm pattern via the `armed` prop. */
+export function Button({
+ variant = 'primary',
+ armed = false,
+ disabled = false,
+ onClick,
+ title,
+ style,
+ children,
+}) {
+ const base = {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 8,
+ whiteSpace: 'nowrap',
+ font: 'inherit',
+ fontSize: '13.5px',
+ fontWeight: 600,
+ borderRadius: 'var(--ht-radius-md)',
+ padding: '8px 18px',
+ cursor: disabled ? 'default' : 'pointer',
+ border: 'none',
+ }
+ const variants = {
+ primary: {
+ color: 'var(--ht-on-accent)',
+ background: disabled
+ ? 'color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))'
+ : 'var(--ht-accent)',
+ },
+ outline: {
+ color: 'var(--ht-ink)',
+ background: 'var(--ht-surface)',
+ border: '1px solid var(--ht-border)',
+ padding: '7px 17px',
+ },
+ ghost: { color: 'var(--ht-ink-muted)', background: 'none' },
+ destructive: armed
+ ? {
+ color: 'var(--ht-surface)',
+ background: 'var(--ht-critical)',
+ border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)',
+ padding: '7px 17px',
+ }
+ : {
+ color: 'var(--ht-critical)',
+ background: 'transparent',
+ border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)',
+ padding: '7px 17px',
+ },
+ }
+ return (
+ {
+ if (!disabled) e.currentTarget.style.filter = 'brightness(0.95)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.filter = ''
+ }}
+ >
+ {children}
+
+ )
+}
diff --git a/web/src/components/ds/core/DropdownMenu.d.ts b/web/src/components/ds/core/DropdownMenu.d.ts
new file mode 100644
index 0000000..3185b32
--- /dev/null
+++ b/web/src/components/ds/core/DropdownMenu.d.ts
@@ -0,0 +1,9 @@
+export interface DropdownMenuProps {
+ open: boolean
+ onClose: () => void
+ align?: 'left' | 'right'
+ top?: number
+ minWidth?: number
+ children: React.ReactNode
+}
+export declare function DropdownMenu(props: DropdownMenuProps): JSX.Element
diff --git a/web/src/components/ds/core/DropdownMenu.jsx b/web/src/components/ds/core/DropdownMenu.jsx
new file mode 100644
index 0000000..d7d2f30
--- /dev/null
+++ b/web/src/components/ds/core/DropdownMenu.jsx
@@ -0,0 +1,35 @@
+import React from 'react'
+
+/** Anchored dropdown surface with backdrop dismissal. Wrap the trigger in a
+ * position:relative parent and render this when open. */
+export function DropdownMenu({
+ open,
+ onClose,
+ align = 'left',
+ top = 36,
+ minWidth = 160,
+ children,
+}) {
+ if (!open) return null
+ return (
+ <>
+
+
+ {children}
+
+ >
+ )
+}
diff --git a/web/src/components/ds/core/EmptyState.d.ts b/web/src/components/ds/core/EmptyState.d.ts
new file mode 100644
index 0000000..dc1bf43
--- /dev/null
+++ b/web/src/components/ds/core/EmptyState.d.ts
@@ -0,0 +1,7 @@
+export interface EmptyStateProps {
+ title: string
+ body?: string
+ /** italic celebration ("Inbox zero.") */
+ celebrate?: boolean
+}
+export declare function EmptyState(props: EmptyStateProps): JSX.Element
diff --git a/web/src/components/ds/core/EmptyState.jsx b/web/src/components/ds/core/EmptyState.jsx
new file mode 100644
index 0000000..f91fe32
--- /dev/null
+++ b/web/src/components/ds/core/EmptyState.jsx
@@ -0,0 +1,33 @@
+import React from 'react'
+
+/** Typographic empty state. celebrate=true is the italic "Inbox zero." treatment. */
+export function EmptyState({ title, body, celebrate = false }) {
+ return (
+
+
+ {title}
+
+ {body && (
+
+ {body}
+
+ )}
+
+ )
+}
diff --git a/web/src/components/ds/core/IconButton.d.ts b/web/src/components/ds/core/IconButton.d.ts
new file mode 100644
index 0000000..47597ad
--- /dev/null
+++ b/web/src/components/ds/core/IconButton.d.ts
@@ -0,0 +1,13 @@
+export interface IconButtonProps {
+ title: string
+ onClick?: () => void
+ /** lit background (menu open / active) */
+ active?: boolean
+ /** "header" = on the accent top bar */
+ tone?: 'default' | 'header'
+ size?: number
+ style?: React.CSSProperties
+ /** a solid 13-16px SVG glyph */
+ children: React.ReactNode
+}
+export declare function IconButton(props: IconButtonProps): JSX.Element
diff --git a/web/src/components/ds/core/IconButton.jsx b/web/src/components/ds/core/IconButton.jsx
new file mode 100644
index 0000000..83fcd4e
--- /dev/null
+++ b/web/src/components/ds/core/IconButton.jsx
@@ -0,0 +1,49 @@
+import React from 'react'
+
+/** 32px square icon action. tone "header" renders on the accent top bar. */
+export function IconButton({
+ title,
+ onClick,
+ active = false,
+ tone = 'default',
+ size = 32,
+ style,
+ children,
+}) {
+ const header = tone === 'header'
+ const fg = header
+ ? 'color-mix(in oklab, var(--ht-header-fg) 85%, transparent)'
+ : 'var(--ht-accent)'
+ const hoverBg = header
+ ? 'color-mix(in oklab, var(--ht-header-fg) 16%, transparent)'
+ : 'var(--ht-accent-soft)'
+ return (
+ {
+ e.currentTarget.style.background = hoverBg
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = active ? hoverBg : 'transparent'
+ }}
+ >
+ {children}
+
+ )
+}
diff --git a/web/src/components/ds/core/Kbd.d.ts b/web/src/components/ds/core/Kbd.d.ts
new file mode 100644
index 0000000..85bda38
--- /dev/null
+++ b/web/src/components/ds/core/Kbd.d.ts
@@ -0,0 +1,4 @@
+export interface KbdProps {
+ children: React.ReactNode
+}
+export declare function Kbd(props: KbdProps): JSX.Element
diff --git a/web/src/components/ds/core/Kbd.jsx b/web/src/components/ds/core/Kbd.jsx
new file mode 100644
index 0000000..5534a82
--- /dev/null
+++ b/web/src/components/ds/core/Kbd.jsx
@@ -0,0 +1,24 @@
+import React from 'react'
+
+/** Keycap. Used in shortcut hints and menu affordances. */
+export function Kbd({ children }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/src/components/ds/core/MenuItem.d.ts b/web/src/components/ds/core/MenuItem.d.ts
new file mode 100644
index 0000000..ee96d77
--- /dev/null
+++ b/web/src/components/ds/core/MenuItem.d.ts
@@ -0,0 +1,9 @@
+export interface MenuItemProps {
+ onClick?: () => void
+ icon?: React.ReactNode
+ shortcut?: React.ReactNode
+ selected?: boolean
+ destructive?: boolean
+ children: React.ReactNode
+}
+export declare function MenuItem(props: MenuItemProps): JSX.Element
diff --git a/web/src/components/ds/core/MenuItem.jsx b/web/src/components/ds/core/MenuItem.jsx
new file mode 100644
index 0000000..f49d431
--- /dev/null
+++ b/web/src/components/ds/core/MenuItem.jsx
@@ -0,0 +1,47 @@
+import React from 'react'
+
+/** One dropdown row. Optional leading glyph and trailing shortcut/hint. */
+export function MenuItem({
+ onClick,
+ icon,
+ shortcut,
+ selected = false,
+ destructive = false,
+ children,
+}) {
+ const fg = destructive ? 'var(--ht-critical)' : selected ? 'var(--ht-accent)' : 'var(--ht-ink)'
+ return (
+ {
+ e.currentTarget.style.background = destructive
+ ? 'var(--ht-critical-soft)'
+ : 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = selected ? 'var(--ht-accent-soft)' : 'transparent'
+ }}
+ >
+ {icon && {icon} }
+ {children}
+ {shortcut}
+
+ )
+}
diff --git a/web/src/components/ds/core/Skeleton.d.ts b/web/src/components/ds/core/Skeleton.d.ts
new file mode 100644
index 0000000..120ca0f
--- /dev/null
+++ b/web/src/components/ds/core/Skeleton.d.ts
@@ -0,0 +1,7 @@
+export interface SkeletonProps {
+ width?: number | string
+ height?: number | string
+ radius?: number | string
+ style?: React.CSSProperties
+}
+export declare function Skeleton(props: SkeletonProps): JSX.Element
diff --git a/web/src/components/ds/core/Skeleton.jsx b/web/src/components/ds/core/Skeleton.jsx
new file mode 100644
index 0000000..3800d66
--- /dev/null
+++ b/web/src/components/ds/core/Skeleton.jsx
@@ -0,0 +1,20 @@
+import React from 'react'
+
+/** Pulsing placeholder bar/shape. */
+export function Skeleton({ width = '100%', height = 12, radius = 4, style }) {
+ return (
+ <>
+
+
+ >
+ )
+}
diff --git a/web/src/components/ds/core/StatusPill.d.ts b/web/src/components/ds/core/StatusPill.d.ts
new file mode 100644
index 0000000..fd96743
--- /dev/null
+++ b/web/src/components/ds/core/StatusPill.d.ts
@@ -0,0 +1,8 @@
+export interface StatusPillProps {
+ /** active | pending | closed | spam | note (unknown = neutral) */
+ status: string
+ /** override the derived label */
+ label?: string
+ style?: React.CSSProperties
+}
+export declare function StatusPill(props: StatusPillProps): JSX.Element
diff --git a/web/src/components/ds/core/StatusPill.jsx b/web/src/components/ds/core/StatusPill.jsx
new file mode 100644
index 0000000..1880301
--- /dev/null
+++ b/web/src/components/ds/core/StatusPill.jsx
@@ -0,0 +1,40 @@
+import React from 'react'
+
+const META = {
+ active: { label: 'Active', fg: 'var(--ht-accent)', bg: 'var(--ht-accent-soft)' },
+ pending: {
+ label: 'Pending',
+ fg: 'var(--ht-warn)',
+ bg: 'color-mix(in oklab, var(--ht-warn) 12%, transparent)',
+ },
+ closed: { label: 'Closed', fg: 'var(--ht-ink-dim)', bg: 'var(--ht-surface-2)' },
+ spam: { label: 'Spam', fg: 'var(--ht-critical)', bg: 'var(--ht-critical-soft)' },
+ note: {
+ label: 'Internal note',
+ fg: 'var(--ht-warn)',
+ bg: 'color-mix(in oklab, var(--ht-warn) 14%, transparent)',
+ },
+}
+
+/** Uppercase status pill; unknown statuses fall back to a neutral pill. */
+export function StatusPill({ status, label, style }) {
+ const m = META[status] || { label: status, fg: 'var(--ht-ink-dim)', bg: 'var(--ht-surface-2)' }
+ return (
+
+ {label || m.label}
+
+ )
+}
diff --git a/web/src/components/ds/core/TagChip.d.ts b/web/src/components/ds/core/TagChip.d.ts
new file mode 100644
index 0000000..26225b0
--- /dev/null
+++ b/web/src/components/ds/core/TagChip.d.ts
@@ -0,0 +1,6 @@
+export interface TagChipProps {
+ label: string
+ /** renders the × when provided */
+ onRemove?: () => void
+}
+export declare function TagChip(props: TagChipProps): JSX.Element
diff --git a/web/src/components/ds/core/TagChip.jsx b/web/src/components/ds/core/TagChip.jsx
new file mode 100644
index 0000000..4d8e3be
--- /dev/null
+++ b/web/src/components/ds/core/TagChip.jsx
@@ -0,0 +1,55 @@
+import React from 'react'
+
+/** Lowercase tag chip with optional remove. */
+export function TagChip({ label, onRemove }) {
+ return (
+
+ {label}
+ {onRemove && (
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/web/src/components/ds/core/TextInput.d.ts b/web/src/components/ds/core/TextInput.d.ts
new file mode 100644
index 0000000..cb69dba
--- /dev/null
+++ b/web/src/components/ds/core/TextInput.d.ts
@@ -0,0 +1,9 @@
+export interface TextInputProps {
+ value?: string
+ onChange?: (e: any) => void
+ onKeyDown?: (e: any) => void
+ placeholder?: string
+ id?: string
+ style?: React.CSSProperties
+}
+export declare function TextInput(props: TextInputProps): JSX.Element
diff --git a/web/src/components/ds/core/TextInput.jsx b/web/src/components/ds/core/TextInput.jsx
new file mode 100644
index 0000000..2ab209c
--- /dev/null
+++ b/web/src/components/ds/core/TextInput.jsx
@@ -0,0 +1,28 @@
+import React from 'react'
+
+/** Small text input (tag popover style). */
+export function TextInput({ value, onChange, onKeyDown, placeholder, id, style }) {
+ return (
+
+ )
+}
diff --git a/web/src/components/ds/core/Toast.d.ts b/web/src/components/ds/core/Toast.d.ts
new file mode 100644
index 0000000..9a661a4
--- /dev/null
+++ b/web/src/components/ds/core/Toast.d.ts
@@ -0,0 +1,8 @@
+export interface ToastProps {
+ message: string
+ detail?: string
+ /** position fixed bottom-right */
+ fixed?: boolean
+ style?: React.CSSProperties
+}
+export declare function Toast(props: ToastProps): JSX.Element
diff --git a/web/src/components/ds/core/Toast.jsx b/web/src/components/ds/core/Toast.jsx
new file mode 100644
index 0000000..39a9811
--- /dev/null
+++ b/web/src/components/ds/core/Toast.jsx
@@ -0,0 +1,22 @@
+import React from 'react'
+
+/** Inverse-fill toast, bottom-right. One msg line + optional detail. Auto-dismiss ~4.2s in-app. */
+export function Toast({ message, detail, fixed = false, style }) {
+ return (
+
+
{message}
+ {detail &&
{detail}
}
+
+ )
+}
diff --git a/web/src/components/ds/inbox/ConversationRow.d.ts b/web/src/components/ds/inbox/ConversationRow.d.ts
new file mode 100644
index 0000000..eabd682
--- /dev/null
+++ b/web/src/components/ds/inbox/ConversationRow.d.ts
@@ -0,0 +1,23 @@
+/** One inbox table row; compose under a 48px header band. */
+export interface ConversationRowProps {
+ customerName: string
+ customerEmail: string
+ subject: string
+ /** excerpt of the latest message */
+ preview?: string
+ /** thread count; empty hides the pill but keeps its column slot */
+ count?: string
+ /** human conversation number (#N) */
+ number?: string
+ /** relative waiting time */
+ time?: string
+ starred?: boolean
+ onStar?: () => void
+ checked?: boolean
+ onCheck?: (e: any) => void
+ showCheckbox?: boolean
+ /** j/k keyboard cursor state */
+ selected?: boolean
+ onClick?: () => void
+}
+export declare function ConversationRow(props: ConversationRowProps): JSX.Element
diff --git a/web/src/components/ds/inbox/ConversationRow.jsx b/web/src/components/ds/inbox/ConversationRow.jsx
new file mode 100644
index 0000000..843ff48
--- /dev/null
+++ b/web/src/components/ds/inbox/ConversationRow.jsx
@@ -0,0 +1,187 @@
+import React from 'react'
+import { Avatar } from '../core/Avatar.jsx'
+
+/** Full-bleed inbox table row: checkbox · customer · star · subject/preview ·
+ * count slot · #number · waiting time. Hairline bottom border. */
+export function ConversationRow({
+ customerName,
+ customerEmail,
+ subject,
+ preview,
+ count = '',
+ number = '',
+ time = '',
+ starred = false,
+ onStar,
+ checked = false,
+ onCheck,
+ showCheckbox = true,
+ selected = false,
+ onClick,
+}) {
+ return (
+ {
+ e.currentTarget.style.background = 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = selected ? 'var(--ht-surface-2)' : 'transparent'
+ }}
+ >
+ {showCheckbox && (
+
e.stopPropagation()}
+ style={{
+ width: 15,
+ height: 15,
+ accentColor: 'var(--ht-accent)',
+ cursor: 'pointer',
+ margin: 0,
+ flexShrink: 0,
+ }}
+ />
+ )}
+
+
+ {customerName}
+
+
+ {customerEmail}
+
+
+
{
+ e.stopPropagation()
+ onStar && onStar()
+ }}
+ style={{
+ width: 22,
+ height: 22,
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: starred ? 'var(--ht-accent)' : 'var(--ht-ink-dim)',
+ background: 'none',
+ border: 'none',
+ borderRadius: 'var(--ht-radius-sm)',
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ >
+
+
+
+
+
+
+ {subject}
+
+
+ {preview}
+
+
+
+
+ {count && (
+
+ {count}
+
+ )}
+
+
+ #
+ {number}
+
+
+ {time}
+
+
+
+ )
+}
diff --git a/web/src/components/ds/inbox/FolderItem.d.ts b/web/src/components/ds/inbox/FolderItem.d.ts
new file mode 100644
index 0000000..9c62a71
--- /dev/null
+++ b/web/src/components/ds/inbox/FolderItem.d.ts
@@ -0,0 +1,12 @@
+export interface FolderItemProps {
+ /** solid 13px SVG glyph */
+ icon: React.ReactNode
+ label: string
+ /** empty string hides the count (zero folders show none) */
+ count?: string
+ active?: boolean
+ /** items present = full-ink label; empty = dimmed */
+ hasItems?: boolean
+ onClick?: () => void
+}
+export declare function FolderItem(props: FolderItemProps): JSX.Element
diff --git a/web/src/components/ds/inbox/FolderItem.jsx b/web/src/components/ds/inbox/FolderItem.jsx
new file mode 100644
index 0000000..d62af32
--- /dev/null
+++ b/web/src/components/ds/inbox/FolderItem.jsx
@@ -0,0 +1,52 @@
+import React from 'react'
+
+/** Sidebar folder row. Folders with items read stronger than empty ones;
+ * the active folder is accent-highlighted. */
+export function FolderItem({ icon, label, count = '', active = false, hasItems = false, onClick }) {
+ const fg = active
+ ? 'var(--ht-accent)'
+ : hasItems
+ ? 'var(--ht-ink)'
+ : 'color-mix(in oklab, var(--ht-ink-dim) 60%, transparent)'
+ return (
+ {
+ e.currentTarget.style.background = 'var(--ht-surface-2)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = active ? 'var(--ht-accent-soft)' : 'transparent'
+ }}
+ >
+ {icon}
+ {label}
+
+ {count}
+
+
+ )
+}
diff --git a/web/src/components/ds/inbox/MessageBand.d.ts b/web/src/components/ds/inbox/MessageBand.d.ts
new file mode 100644
index 0000000..585b7a0
--- /dev/null
+++ b/web/src/components/ds/inbox/MessageBand.d.ts
@@ -0,0 +1,19 @@
+export interface MessageBandProps {
+ /** inbound (customer) | outbound (Agent reply) | note (internal) */
+ kind?: 'inbound' | 'outbound' | 'note'
+ fromLabel: string
+ fromAddr?: string
+ time?: string
+ /** outbound only: "sent" | "pending" */
+ delivery?: string
+ /** read receipt time — renders the eye line */
+ viewedAt?: string
+ /** loud failed-delivery banner */
+ failed?: boolean
+ /** hairline top within same-speaker runs */
+ sameSpeakerAsPrev?: boolean
+ /** drives the avatar tone */
+ email?: string
+ children: React.ReactNode
+}
+export declare function MessageBand(props: MessageBandProps): JSX.Element
diff --git a/web/src/components/ds/inbox/MessageBand.jsx b/web/src/components/ds/inbox/MessageBand.jsx
new file mode 100644
index 0000000..0163971
--- /dev/null
+++ b/web/src/components/ds/inbox/MessageBand.jsx
@@ -0,0 +1,131 @@
+import React from 'react'
+import { Avatar } from '../core/Avatar.jsx'
+import { StatusPill } from '../core/StatusPill.jsx'
+
+/** Full-bleed thread band. Fill carries who's-who: customer = surface,
+ * Agent reply = 6% accent tint, internal note = 9% warn tint + edge bar.
+ * Hairline top only within a same-speaker run. */
+export function MessageBand({
+ kind = 'inbound',
+ fromLabel,
+ fromAddr,
+ time,
+ delivery,
+ viewedAt,
+ failed = false,
+ sameSpeakerAsPrev = false,
+ email,
+ children,
+}) {
+ const bg =
+ kind === 'note'
+ ? 'color-mix(in oklab, var(--ht-warn) 9%, var(--ht-surface))'
+ : kind === 'outbound'
+ ? 'color-mix(in oklab, var(--ht-accent) 6%, var(--ht-surface))'
+ : 'var(--ht-surface)'
+ const fromColor =
+ kind === 'note' ? 'var(--ht-warn)' : kind === 'inbound' ? 'var(--ht-ink)' : 'var(--ht-accent)'
+ return (
+
+
+
+
+ {fromLabel}
+ {kind === 'note' && (
+
+ )}
+
+ {fromAddr}
+
+
+ {kind === 'outbound' && !failed && delivery && (
+
+ {delivery === 'pending' ? 'Sending…' : 'Sent'}
+
+ )}
+
+ {time}
+
+
+
+ {children}
+
+ {viewedAt && (
+
+
+
+
+
Customer viewed {viewedAt}
+
+ )}
+ {failed && (
+
+
+ This reply didn't reach the customer.
+
+
+ Delivery failed — Helpthread will keep retrying.
+
+
+ )}
+
+
+ )
+}
diff --git a/web/src/components/ds/inbox/ToolbarBand.d.ts b/web/src/components/ds/inbox/ToolbarBand.d.ts
new file mode 100644
index 0000000..bbd763c
--- /dev/null
+++ b/web/src/components/ds/inbox/ToolbarBand.d.ts
@@ -0,0 +1,7 @@
+export interface ToolbarBandProps {
+ /** accent = over the sheet; panel = over the context panel */
+ tone?: 'accent' | 'panel'
+ children?: React.ReactNode
+ style?: React.CSSProperties
+}
+export declare function ToolbarBand(props: ToolbarBandProps): JSX.Element
diff --git a/web/src/components/ds/inbox/ToolbarBand.jsx b/web/src/components/ds/inbox/ToolbarBand.jsx
new file mode 100644
index 0000000..8ba65a6
--- /dev/null
+++ b/web/src/components/ds/inbox/ToolbarBand.jsx
@@ -0,0 +1,27 @@
+import React from 'react'
+
+/** The 48px band that caps the work sheet. tone "accent" (8% tint) over the
+ * thread/list; tone "panel" (neutral gray) continues over the context panel. */
+export function ToolbarBand({ tone = 'accent', children, style }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/web/src/globals.d.ts b/web/src/globals.d.ts
new file mode 100644
index 0000000..c5278e8
--- /dev/null
+++ b/web/src/globals.d.ts
@@ -0,0 +1,2 @@
+// CSS side-effect imports (Next handles them at build; tsc just needs the shape).
+declare module '*.css'
diff --git a/web/src/lib/actions.ts b/web/src/lib/actions.ts
new file mode 100644
index 0000000..af117f3
--- /dev/null
+++ b/web/src/lib/actions.ts
@@ -0,0 +1,149 @@
+'use server'
+
+/**
+ * Server actions — the ONLY write path from the browser to the Agent Inbox
+ * API. Each action runs server-side (the Bearer token never leaves the
+ * server), performs one API call, revalidates the affected routes, and
+ * returns a plain serializable result so client components can render
+ * precise outcomes (notably the reply flow's three failure modes, spec §4a).
+ */
+
+import { revalidatePath } from 'next/cache'
+import {
+ ApiError,
+ type ConversationFolder,
+ type ConversationStatus,
+ type ConversationSummary,
+ deleteConversation,
+ listConversations,
+ postNote,
+ postReply,
+ putAssignee,
+ putTags,
+ setStatus,
+} from './api'
+
+export interface ActionResult {
+ ok: boolean
+ /** Machine-readable API error code when `ok` is false (spec §3). */
+ code?: string
+ message?: string
+}
+
+/**
+ * Send a reply. The client supplies the Idempotency-Key and MUST reuse it
+ * when retrying after `retry_in_progress` or a network failure — the key is
+ * what makes a retry safe (spec §4a). `html` is optional — the composer only
+ * sends it when the Agent actually used the format toolbar (spec §4a: HTML
+ * is optional alongside the required plain text).
+ */
+export async function sendReplyAction(
+ conversationId: string,
+ text: string,
+ idempotencyKey: string,
+ html?: string,
+): Promise {
+ try {
+ await postReply(conversationId, { text, html }, idempotencyKey)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+/** Post an internal note (spec §4c) — never emailed, never touches the send path. */
+export async function postNoteAction(conversationId: string, text: string): Promise {
+ try {
+ await postNote(conversationId, text)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+export async function setStatusAction(
+ conversationId: string,
+ status: ConversationStatus,
+): Promise {
+ try {
+ await setStatus(conversationId, status)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+/** Replace-set tags update (spec §4e) — the caller passes the FULL next tag set. */
+export async function putTagsAction(conversationId: string, tags: string[]): Promise {
+ try {
+ await putTags(conversationId, tags)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+/** Claim or release the single-Agent assignee flag (spec §4f). */
+export async function putAssigneeAction(
+ conversationId: string,
+ assignee: 'me' | null,
+): Promise {
+ try {
+ await putAssignee(conversationId, assignee)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+export async function deleteConversationAction(conversationId: string): Promise {
+ try {
+ await deleteConversation(conversationId)
+ revalidatePath(`/conversations/${conversationId}`)
+ revalidatePath('/inbox/[folder]', 'page')
+ return { ok: true }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { ok: false, code: error.code, message: error.message }
+ }
+ return { ok: false, code: 'network', message: 'Could not reach the server.' }
+ }
+}
+
+/**
+ * One more page of a keyset-paginated folder (Closed/Spam only — the other
+ * five folders fetch unpaged). Plain data in, plain data out so the client
+ * screen can append it to in-memory state without a route navigation.
+ */
+export async function loadOlderAction(
+ folder: Extract,
+ cursor: string,
+): Promise<{ conversations: ConversationSummary[]; nextCursor: string | null }> {
+ const page = await listConversations({ folder, cursor })
+ return { conversations: page.conversations, nextCursor: page.nextCursor }
+}
diff --git a/web/src/lib/api-types.ts b/web/src/lib/api-types.ts
new file mode 100644
index 0000000..e6a1ba6
--- /dev/null
+++ b/web/src/lib/api-types.ts
@@ -0,0 +1,44 @@
+/**
+ * Wire types for the Agent Inbox API, 1:1 with `specs/api/agent-inbox-v1.md`
+ * (v1.1) §2. Pure types with no imports, so BOTH sides can use them: the
+ * server-only client (`api.ts`) and client components receiving fetched
+ * data as props.
+ */
+
+export type ConversationStatus = 'active' | 'pending' | 'closed' | 'spam'
+export type ConversationFolder = 'open' | 'closed' | 'spam'
+
+export interface ConversationSummary {
+ id: string
+ number: number
+ subject: string
+ customerEmail: string
+ status: ConversationStatus
+ threadCount: number
+ preview: string
+ tags: string[]
+ assignee: 'me' | null
+ createdAt: string
+ updatedAt: string
+}
+
+export interface ThreadView {
+ id: string
+ direction: 'inbound' | 'outbound' | 'note'
+ from: string
+ bodyText: string | null
+ /** ⚠ UNTRUSTED, UNSANITIZED (spec §5) — render only through SanitizedHtml. */
+ bodyHtml: string | null
+ deliveryStatus: 'pending' | 'sent' | 'failed' | null
+ customerViewedAt: string | null
+ createdAt: string
+}
+
+export interface ConversationDetail extends ConversationSummary {
+ threads: ThreadView[]
+}
+
+export interface ConversationListResponse {
+ conversations: ConversationSummary[]
+ nextCursor: string | null
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..1aeded8
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,183 @@
+/**
+ * The Agent Inbox API client — typed 1:1 against `specs/api/agent-inbox-v1.md`
+ * (v1.1). SERVER-ONLY by construction: the Bearer token is a service
+ * credential that grants the whole inbox (spec §5), so it lives in server
+ * env and every call happens from a server component or server action —
+ * the `server-only` import makes a client-bundle inclusion a build error,
+ * not a leak.
+ *
+ * The client is a pure API consumer (CHARTER.md's API-first rule): it never
+ * composes subjects, recipients, or threading headers, and treats
+ * `nextCursor` as an opaque token to echo back (spec §3a).
+ */
+
+import 'server-only'
+import type {
+ ConversationDetail,
+ ConversationFolder,
+ ConversationListResponse,
+ ConversationStatus,
+ ConversationSummary,
+ ThreadView,
+} from './api-types'
+import { AUTH_ERROR_DIGEST } from './auth-error'
+
+export type * from './api-types'
+
+/**
+ * A non-2xx API outcome, carrying the spec's machine-readable error `code`
+ * (spec §3) so callers can branch precisely — notably `retry_in_progress`
+ * (retry the SAME Idempotency-Key later) vs `send_failed` (nothing was
+ * delivered; the draft must be preserved).
+ */
+export class ApiError extends Error {
+ readonly status: number
+ readonly code: string
+ // Optional Next.js error digest. Set to AUTH_ERROR_DIGEST on a 401 so the
+ // client error boundary can route to AuthFailure even in production, where
+ // Server Component error *messages* are stripped and only `digest` survives.
+ readonly digest?: string
+
+ constructor(status: number, code: string, message: string, digest?: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.code = code
+ this.digest = digest
+ }
+}
+
+/** Upstream fetch timeout — a hung API must fail fast, not hang the render. */
+const REQUEST_TIMEOUT_MS = 15_000
+
+function config(): { baseUrl: string; token: string } {
+ const baseUrl = process.env.HELPTHREAD_API_URL
+ const token = process.env.HELPTHREAD_API_TOKEN
+ // A deployment MUST set both. Falling back to the dev harness's values in
+ // production would silently point the app at localhost with a well-known
+ // token — fail loud at the first RUNTIME request instead. Skipped during
+ // `next build` (NEXT_PHASE), where prerendering runs in production mode
+ // without the runtime env and dev defaults are harmless.
+ const isBuild = process.env.NEXT_PHASE === 'phase-production-build'
+ if (
+ process.env.NODE_ENV === 'production' &&
+ !isBuild &&
+ (baseUrl === undefined || token === undefined)
+ ) {
+ throw new Error(
+ 'HELPTHREAD_API_URL and HELPTHREAD_API_TOKEN must be set in production — refusing to fall back to dev defaults.',
+ )
+ }
+ // Dev defaults match the HT-24 harness (`npm run dev:api`); the default
+ // token is the harness's clearly-dev-only value.
+ return {
+ baseUrl: (baseUrl ?? 'http://localhost:8787').replace(/\/+$/, ''),
+ token: token ?? 'helpthread-dev-token',
+ }
+}
+
+async function request(
+ path: string,
+ init: { method?: string; body?: unknown; headers?: Record } = {},
+): Promise {
+ const { baseUrl, token } = config()
+ const response = await fetch(`${baseUrl}${path}`, {
+ method: init.method ?? 'GET',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(init.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+ ...init.headers,
+ },
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
+ // Authenticated support data: the API says no-store (spec §3) and the
+ // client agrees — every render sees current truth.
+ cache: 'no-store',
+ // Bound every call so a hung upstream fails fast (→ error boundary /
+ // action network-error) rather than hanging the server render.
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+
+ if (response.status === 204) {
+ return undefined as T
+ }
+
+ if (!response.ok) {
+ // The spec's error envelope is `{ error: { code, message } }` on every
+ // non-2xx; anything else (a proxy error page, a network hiccup mid-body)
+ // still becomes a typed ApiError rather than an unhandled throw.
+ let code = 'server_error'
+ let message = `Request failed with status ${response.status}.`
+ try {
+ const body = (await response.json()) as { error?: { code?: string; message?: string } }
+ if (body.error?.code) code = body.error.code
+ if (body.error?.message) message = body.error.message
+ } catch {
+ // Non-JSON error body — keep the generic message.
+ }
+ // A 401 means the deployment's own Bearer token is missing or wrong —
+ // not a user failing to log in (there is no login). It must route to the
+ // AuthFailure screen via a client error boundary (`app/**/error.tsx`). In
+ // production Next.js strips a Server Component error's `message` and
+ // forwards only `error.digest`, so the digest — not the message — is what
+ // `components/AppError.tsx` matches on. The `unauthorized:` message prefix
+ // is kept for dev/server logs and as a belt-and-suspenders fallback.
+ if (response.status === 401) {
+ throw new ApiError(response.status, code, `unauthorized:${message}`, AUTH_ERROR_DIGEST)
+ }
+ throw new ApiError(response.status, code, message)
+ }
+
+ return (await response.json()) as T
+}
+
+export function listConversations(options: {
+ folder: ConversationFolder
+ cursor?: string
+ limit?: number
+}): Promise {
+ const params = new URLSearchParams({ status: options.folder })
+ if (options.cursor !== undefined) params.set('cursor', options.cursor)
+ if (options.limit !== undefined) params.set('limit', String(options.limit))
+ return request(`/api/v1/conversations?${params}`)
+}
+
+export function getConversation(id: string): Promise {
+ return request(`/api/v1/conversations/${id}`)
+}
+
+/**
+ * Send a reply. `idempotencyKey` is REQUIRED by the API (spec §4a) and must
+ * be reused verbatim when retrying the same logical send — a 409
+ * `retry_in_progress` means retry LATER with the SAME key, never a new one.
+ */
+export function postReply(
+ id: string,
+ input: { text: string; html?: string },
+ idempotencyKey: string,
+): Promise {
+ return request(`/api/v1/conversations/${id}/replies`, {
+ method: 'POST',
+ body: input,
+ headers: { 'Idempotency-Key': idempotencyKey },
+ })
+}
+
+export function setStatus(id: string, status: ConversationStatus): Promise {
+ return request(`/api/v1/conversations/${id}`, { method: 'PATCH', body: { status } })
+}
+
+export function postNote(id: string, text: string): Promise {
+ return request(`/api/v1/conversations/${id}/notes`, { method: 'POST', body: { text } })
+}
+
+export function putTags(id: string, tags: string[]): Promise {
+ return request(`/api/v1/conversations/${id}/tags`, { method: 'PUT', body: { tags } })
+}
+
+export function putAssignee(id: string, assignee: 'me' | null): Promise {
+ return request(`/api/v1/conversations/${id}/assignee`, { method: 'PUT', body: { assignee } })
+}
+
+export function deleteConversation(id: string): Promise {
+ return request(`/api/v1/conversations/${id}`, { method: 'DELETE' })
+}
diff --git a/web/src/lib/auth-error.ts b/web/src/lib/auth-error.ts
new file mode 100644
index 0000000..87e202d
--- /dev/null
+++ b/web/src/lib/auth-error.ts
@@ -0,0 +1,14 @@
+/**
+ * Sentinel `error.digest` set by `lib/api.ts` on a 401 so the client error
+ * boundary can select the AuthFailure screen.
+ *
+ * WHY digest and not message: errors thrown from Server Components are
+ * sanitized in production — Next.js replaces `error.message` with a generic
+ * string and forwards only `error.digest` to the client boundary. A custom
+ * digest survives (the same channel `notFound()`/`redirect()` use), so it is
+ * the one signal reliable in BOTH dev and prod.
+ *
+ * This module intentionally has no `server-only` import so it can be shared by
+ * the server-only API client AND the client-side `AppError` boundary.
+ */
+export const AUTH_ERROR_DIGEST = 'unauthorized'
diff --git a/web/src/lib/drafts.ts b/web/src/lib/drafts.ts
new file mode 100644
index 0000000..4e53e01
--- /dev/null
+++ b/web/src/lib/drafts.ts
@@ -0,0 +1,69 @@
+'use client'
+
+/**
+ * Draft state — `localStorage['helpthread.drafts']` is a JSON map of
+ * conversationId → plain-text reply draft. `useDrafts` (read) backs the
+ * Drafts folder's membership; `getDraft` / `writeDraft` / `clearDraft`
+ * (write) are used by the conversation screen's summoned reply composer —
+ * the only writer. Reply drafts only: internal notes are never persisted
+ * here.
+ */
+
+import { useCallback, useEffect, useState } from 'react'
+
+const STORAGE_KEY = 'helpthread.drafts'
+const CHANGE_EVENT = 'helpthread:drafts-changed'
+
+function readDraftsMap(): Record {
+ if (typeof window === 'undefined') return {}
+ try {
+ const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}')
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
+ return parsed as Record
+ } catch {
+ return {}
+ }
+}
+
+/** The saved draft for one conversation, or `null` if none is saved. */
+export function getDraft(conversationId: string): string | null {
+ return readDraftsMap()[conversationId] ?? null
+}
+
+/** Save the draft for one conversation; empty text removes it. */
+export function writeDraft(conversationId: string, text: string): void {
+ if (typeof window === 'undefined') return
+ const map = readDraftsMap()
+ if (text.length === 0) {
+ delete map[conversationId]
+ } else {
+ map[conversationId] = text
+ }
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
+ window.dispatchEvent(new Event(CHANGE_EVENT))
+}
+
+/** Remove a conversation's draft outright (e.g. after a successful send). */
+export function clearDraft(conversationId: string): void {
+ writeDraft(conversationId, '')
+}
+
+export function useDrafts(): Record {
+ const [drafts, setDrafts] = useState>({})
+
+ const refresh = useCallback(() => setDrafts(readDraftsMap()), [])
+
+ // Same cross-instance sync as `useStarred`: a `storage` event only fires
+ // in OTHER tabs, so a custom event closes the gap for the tab that wrote.
+ useEffect(() => {
+ refresh()
+ window.addEventListener(CHANGE_EVENT, refresh)
+ window.addEventListener('storage', refresh)
+ return () => {
+ window.removeEventListener(CHANGE_EVENT, refresh)
+ window.removeEventListener('storage', refresh)
+ }
+ }, [refresh])
+
+ return drafts
+}
diff --git a/web/src/lib/folder-counts.ts b/web/src/lib/folder-counts.ts
new file mode 100644
index 0000000..edc5e3d
--- /dev/null
+++ b/web/src/lib/folder-counts.ts
@@ -0,0 +1,40 @@
+/**
+ * Sidebar/dashboard folder counts — one server-side fetch (open(50) +
+ * closed/spam first pages), shared by the shell rail (`FolderNav`) and
+ * `/dashboard` so the derivation lives in exactly one place. Unassigned,
+ * Mine, and Assigned split client-of-the-server from the single open(50)
+ * fetch (assignment folders show up to 50 unpaged, per the fidelity
+ * checklist); Closed/Spam show "50+" once their first page's `nextCursor`
+ * is non-null. Starred/Drafts are localStorage-only and merged in
+ * client-side — see `mergeFolderCounts`.
+ */
+
+import { listConversations } from './api'
+import type { ServerFolderCounts } from './folders'
+
+const COUNT_LIMIT = 50
+
+function pageLabel(count: number, hasMore: boolean): string {
+ if (count === 0) return ''
+ return hasMore ? `${COUNT_LIMIT}+` : String(count)
+}
+
+export async function loadFolderCounts(): Promise {
+ const [openPage, closedPage, spamPage] = await Promise.all([
+ listConversations({ folder: 'open', limit: COUNT_LIMIT }),
+ listConversations({ folder: 'closed', limit: COUNT_LIMIT }),
+ listConversations({ folder: 'spam', limit: COUNT_LIMIT }),
+ ])
+
+ const unassigned = openPage.conversations.filter((c) => c.assignee === null).length
+ const mine = openPage.conversations.filter((c) => c.assignee === 'me').length
+ const assigned = openPage.conversations.filter((c) => c.assignee !== null).length
+
+ return {
+ unassigned: unassigned > 0 ? String(unassigned) : '',
+ mine: mine > 0 ? String(mine) : '',
+ assigned: assigned > 0 ? String(assigned) : '',
+ closed: pageLabel(closedPage.conversations.length, closedPage.nextCursor !== null),
+ spam: pageLabel(spamPage.conversations.length, spamPage.nextCursor !== null),
+ }
+}
diff --git a/web/src/lib/folders.ts b/web/src/lib/folders.ts
new file mode 100644
index 0000000..13da29a
--- /dev/null
+++ b/web/src/lib/folders.ts
@@ -0,0 +1,121 @@
+/**
+ * The UI's seven-folder taxonomy — a view over the API's three-folder
+ * `ConversationStatus`/`ConversationFolder` model (`api-types.ts`), not a
+ * wire concept of its own. Unassigned/Mine/Assigned are derived client- and
+ * server-side from `open` conversations' `assignee`; Starred/Drafts are
+ * derived from localStorage. Shared by the folder rail, the dashboard's
+ * mailbox card, and the inbox list screen so the labels/icons/empty-copy
+ * live in exactly one place.
+ */
+
+export type AppFolder =
+ | 'unassigned'
+ | 'mine'
+ | 'starred'
+ | 'drafts'
+ | 'assigned'
+ | 'closed'
+ | 'spam'
+
+export const FOLDER_ORDER: readonly AppFolder[] = [
+ 'unassigned',
+ 'mine',
+ 'starred',
+ 'drafts',
+ 'assigned',
+ 'closed',
+ 'spam',
+]
+
+export function isAppFolder(value: string): value is AppFolder {
+ return (FOLDER_ORDER as readonly string[]).includes(value)
+}
+
+export const FOLDER_LABELS: Record = {
+ unassigned: 'Unassigned',
+ mine: 'Mine',
+ starred: 'Starred',
+ drafts: 'Drafts',
+ assigned: 'Assigned',
+ closed: 'Closed',
+ spam: 'Spam',
+}
+
+/** Solid 24x24 glyph paths (Material-style), one per folder. */
+export const FOLDER_ICON_PATHS: Record = {
+ unassigned:
+ 'M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z',
+ mine: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
+ starred:
+ 'M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
+ drafts:
+ 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.42l-2.34-2.34a1 1 0 0 0-1.42 0l-1.83 1.83 3.75 3.75 1.84-1.82z',
+ assigned:
+ 'M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zM8 11c1.66 0 3-1.34 3-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05C16.16 13.87 17 15 17 16.5V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z',
+ closed:
+ 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
+ spam: 'M12 2 1 21h22L12 2zm1 14h-2v2h2v-2zm0-6h-2v4h2v-4z',
+}
+
+export const EMPTY_COPY: Record = {
+ unassigned: {
+ title: 'Inbox zero.',
+ body: 'Every customer has an answer. New email lands here the moment it arrives.',
+ celebrate: true,
+ },
+ mine: {
+ title: 'Nothing assigned to you',
+ body: 'Conversations you take with the assignee control land here.',
+ celebrate: false,
+ },
+ assigned: {
+ title: 'Nothing assigned',
+ body: 'Assigned conversations land here.',
+ celebrate: false,
+ },
+ starred: {
+ title: 'No starred conversations',
+ body: 'Star a conversation from its page to pin it here for quick return.',
+ celebrate: false,
+ },
+ drafts: {
+ title: 'No drafts',
+ body: "Replies you start writing but don't send wait here until you're ready.",
+ celebrate: false,
+ },
+ closed: {
+ title: 'Nothing closed yet',
+ body: 'Conversations you close move here. Replying to one reopens it.',
+ celebrate: false,
+ },
+ spam: {
+ title: 'No spam',
+ body: 'Conversations you mark as spam move here, out of your way.',
+ celebrate: false,
+ },
+}
+
+/** Server-derived counts for the five API-backed folders (spec §3a lists have no totals). */
+export interface ServerFolderCounts {
+ unassigned: string
+ mine: string
+ assigned: string
+ closed: string
+ spam: string
+}
+
+/** Merge the server counts with the two localStorage-only folders' live counts. */
+export function mergeFolderCounts(
+ server: ServerFolderCounts,
+ local: { starred: number; drafts: number },
+): Record {
+ return {
+ unassigned: server.unassigned,
+ mine: server.mine,
+ starred: local.starred > 0 ? String(local.starred) : '',
+ drafts: local.drafts > 0 ? String(local.drafts) : '',
+ assigned: server.assigned,
+ closed: server.closed,
+ spam: server.spam,
+ }
+}
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
new file mode 100644
index 0000000..0a38298
--- /dev/null
+++ b/web/src/lib/format.ts
@@ -0,0 +1,63 @@
+/**
+ * Display formatting per the design system's content rules (its README):
+ * relative times ("7h", "yesterday", "just now" — never "now ago"),
+ * customers appear by a name derived from their email, tabular numerals are
+ * the components' job. Pure functions, shared by server and client.
+ */
+
+/** "just now", "12m", "7h", "yesterday", "6d", then "Mar 3" / "Mar 3, 2025". */
+export function relativeTime(iso: string, now: Date = new Date()): string {
+ const then = new Date(iso)
+ const ms = now.getTime() - then.getTime()
+ const minutes = Math.floor(ms / 60_000)
+ if (minutes < 1) return 'just now'
+ if (minutes < 60) return `${minutes}m`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h`
+ const days = Math.floor(hours / 24)
+ if (days === 1) return 'yesterday'
+ if (days < 7) return `${days}d`
+ const sameYear = then.getFullYear() === now.getFullYear()
+ return then.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ })
+}
+
+/** "jordan@brightpine.co" → "Jordan"; "sam.torres@…" → "Sam Torres". */
+export function nameFromEmail(email: string): string {
+ const local = email.split('@')[0] ?? email
+ return local
+ .split(/[._-]+/)
+ .filter((part) => part.length > 0)
+ .map((part) => part[0].toUpperCase() + part.slice(1))
+ .join(' ')
+}
+
+/** "Jun 19" / "Jun 19, 2025" (year only when not the current year) — used
+ * for the context panel's Previous conversations rows. */
+export function shortDate(iso: string, now: Date = new Date()): string {
+ const then = new Date(iso)
+ const sameYear = then.getFullYear() === now.getFullYear()
+ return then.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ })
+}
+
+/** Message-band timestamp: same calendar day → absolute clock time
+ * ("6:10 PM"); older → the short date. Unlike `relativeTime`, this never
+ * ages in place while a conversation is open. */
+export function messageTime(iso: string, now: Date = new Date()): string {
+ const then = new Date(iso)
+ const sameDay =
+ then.getFullYear() === now.getFullYear() &&
+ then.getMonth() === now.getMonth() &&
+ then.getDate() === now.getDate()
+ if (sameDay) {
+ return then.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
+ }
+ return shortDate(iso, now)
+}
diff --git a/web/src/lib/starred.ts b/web/src/lib/starred.ts
new file mode 100644
index 0000000..bd96410
--- /dev/null
+++ b/web/src/lib/starred.ts
@@ -0,0 +1,68 @@
+'use client'
+
+/**
+ * Star state — client-only, no backend affordance yet (fidelity checklist).
+ * Persisted as a plain id array in `localStorage['helpthread.starred']`.
+ * The conversation-view toolbar star is a LATER increment; this module and
+ * `useStarred` are wired into the inbox row's star toggle only.
+ *
+ * Two independent components read this in the same tab at once — the folder
+ * rail's count and the inbox row's toggle — and the browser's `storage`
+ * event only fires in OTHER tabs, not the one that made the write. A custom
+ * event closes that gap so both instances stay in sync without a shared
+ * store.
+ */
+
+import { useCallback, useEffect, useState } from 'react'
+
+const STORAGE_KEY = 'helpthread.starred'
+const CHANGE_EVENT = 'helpthread:starred-changed'
+
+function readStarred(): string[] {
+ if (typeof window === 'undefined') return []
+ try {
+ const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]')
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
+ } catch {
+ return []
+ }
+}
+
+function writeStarred(ids: string[]): void {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(ids))
+ window.dispatchEvent(new Event(CHANGE_EVENT))
+}
+
+export function useStarred(): {
+ starredIds: string[]
+ isStarred: (id: string) => boolean
+ toggle: (id: string) => void
+} {
+ const [starredIds, setStarredIds] = useState([])
+
+ // Adopt the persisted list once mounted — same hydration-safety pattern as
+ // `ThemeProvider`: server render and first client render must agree — then
+ // stay synced with writes from any other mounted instance of this hook.
+ useEffect(() => {
+ setStarredIds(readStarred())
+ function onChange(): void {
+ setStarredIds(readStarred())
+ }
+ window.addEventListener(CHANGE_EVENT, onChange)
+ window.addEventListener('storage', onChange)
+ return () => {
+ window.removeEventListener(CHANGE_EVENT, onChange)
+ window.removeEventListener('storage', onChange)
+ }
+ }, [])
+
+ const toggle = useCallback((id: string) => {
+ const current = readStarred()
+ const next = current.includes(id) ? current.filter((x) => x !== id) : [...current, id]
+ writeStarred(next)
+ }, [])
+
+ const isStarred = useCallback((id: string) => starredIds.includes(id), [starredIds])
+
+ return { starredIds, isStarred, toggle }
+}
diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts
new file mode 100644
index 0000000..141ca03
--- /dev/null
+++ b/web/src/lib/theme.ts
@@ -0,0 +1,28 @@
+/**
+ * Theme state shape and the pre-hydration init script — a plain module (no
+ * 'use client', no React) so it can be imported by both the server-rendered
+ * root layout (for the inline `