diff --git a/CHANGELOG.md b/CHANGELOG.md
index 788c3e3..cb0dc62 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@ All notable user-visible changes are recorded here. Versions follow [Semantic Ve
## Unreleased
+### Added
+
+- A feedback form inside the web app. It opens from the account menu and the
+ Account page, and from the moments where something can go wrong: starting a
+ session, the session password gate, a session that ended, the vault setup
+ and unlock screens, error notices, the empty sessions list, and the delete
+ account form. Messages are kept by the service and, with `FEEDBACK_TO`
+ set, forwarded by email. Nothing from a terminal is attached.
+
### Fixed
- Widened the vault password fields on the vault setup, unlock, and Account
diff --git a/app/.env.example b/app/.env.example
index eeb0975..ccecd7f 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -21,3 +21,6 @@ MAIL_PROVIDER=sendgrid
MAIL_API_KEY=
MAIL_FROM=shell.online
# MAIL_API_URL=https://api.example.com/emails
+
+# Where feedback sent from the app is forwarded. Empty keeps it in the database only.
+FEEDBACK_TO=
diff --git a/app/README.md b/app/README.md
index ebd09a2..88c038a 100644
--- a/app/README.md
+++ b/app/README.md
@@ -41,6 +41,7 @@ The client reads `VITE_FIREBASE_*` at build time. The server uses:
| `RELAY_URL` | Relay proxied through `/relay/*` |
| `MAIL_API_KEY`, `MAIL_FROM` | Optional invitation email |
| `MAIL_PROVIDER`, `MAIL_API_URL` | Optional non-SendGrid JSON provider |
+| `FEEDBACK_TO` | Optional address that feedback sent from the app is forwarded to |
| `TRUST_PROXY` | Set to `1` only behind a trusted proxy |
See [`.env.example`](.env.example) for the complete development configuration.
@@ -78,6 +79,8 @@ npx wrangler deploy --config wrangler.deploy.jsonc
```
Set `MAIL_API_KEY` with `wrangler secret put` if invitation email is enabled.
+Set `FEEDBACK_TO` as a Worker variable to have feedback sent from the app
+forwarded by email; without it, feedback is kept in the `feedback` table only.
Keep Hyperdrive query caching disabled because the app depends on read-after-write
consistency.
diff --git a/app/docker-compose.yml b/app/docker-compose.yml
index 79f510a..4a18847 100644
--- a/app/docker-compose.yml
+++ b/app/docker-compose.yml
@@ -45,6 +45,8 @@ services:
# Only true behind a proxy that rewrites X-Forwarded-For; believing it
# otherwise lets any caller pick a new address per request.
TRUST_PROXY: ${TRUST_PROXY:-0}
+ # Where feedback sent from the app is forwarded. Empty keeps it in the database only.
+ FEEDBACK_TO: ${FEEDBACK_TO:-}
ports:
- "${PORT:-8080}:8080"
restart: unless-stopped
diff --git a/app/server/app.test.ts b/app/server/app.test.ts
index 14e66f4..545254d 100644
--- a/app/server/app.test.ts
+++ b/app/server/app.test.ts
@@ -2526,3 +2526,58 @@ describe("team audit key", () => {
});
});
});
+
+describe("feedback", () => {
+ const message = {
+ kind: "problem",
+ body: "The gate never opened.",
+ surface: "session-gate",
+ route: "/sessions?open=s1#k3y",
+ app_version: "0.15.1",
+ can_reply: true,
+ context: { host: "laptop", status: "ended" },
+ };
+
+ it("keeps a message with who sent it and where from", async () => {
+ const posted = await call("POST", "/api/feedback", { auth: await idToken(), body: message });
+ expect(posted.status).toBe(201);
+ expect(posted.body.feedback.id).toMatch(/^fbk_/);
+ const [kept] = await store.feedback();
+ expect(kept).toMatchObject({
+ uid: "uid-1",
+ email: "ana@example.com",
+ kind: "problem",
+ body: "The gate never opened.",
+ surface: "session-gate",
+ route: "/sessions",
+ appVersion: "0.15.1",
+ canReply: true,
+ context: { host: "laptop", status: "ended" },
+ });
+ expect(kept.orgId).toBeTruthy();
+ });
+
+ it("refuses without a signed-in user", async () => {
+ const posted = await call("POST", "/api/feedback", { body: message });
+ expect(posted.status).toBe(401);
+ expect(await store.feedback()).toEqual([]);
+ });
+
+ it("refuses an empty message", async () => {
+ const posted = await call("POST", "/api/feedback", { auth: await idToken(), body: { ...message, body: " " } });
+ expect(posted.status).toBe(400);
+ expect(posted.body.error).toBe("write something first");
+ });
+
+ it("stops a flood from one person", async () => {
+ for (let index = 0; index < 5; index += 1) {
+ expect((await call("POST", "/api/feedback", { auth: await idToken(), body: message })).status).toBe(201);
+ }
+ const sixth = await call("POST", "/api/feedback", { auth: await idToken(), body: message });
+ expect(sixth.status).toBe(429);
+ expect(Number(sixth.headers["Retry-After"])).toBeGreaterThan(0);
+ /* Another person is not the flood. */
+ const other = await call("POST", "/api/feedback", { auth: await idToken({ sub: "uid-2", email: "bo@example.com" }), body: message });
+ expect(other.status).toBe(201);
+ });
+});
diff --git a/app/server/app.ts b/app/server/app.ts
index f814ee8..a074a34 100644
--- a/app/server/app.ts
+++ b/app/server/app.ts
@@ -44,6 +44,7 @@ import {
import { recordAudit, assignSession, auditCsv, SEALED_KINDS } from "./routes/audit";
import { addComment, inbox, notifyAssigned, notifySessionStarted } from "./routes/social";
import { deleteAccount } from "./routes/account";
+import { submitFeedback } from "./routes/feedback";
import { callerAddress, rateLimiter } from "./lib/rate-limit";
import { logMailer, type Mailer } from "./lib/mail";
@@ -70,6 +71,11 @@ export interface AppOptions {
* an invite whose link is perfectly good.
*/
mailer?: Mailer;
+ /**
+ * Where feedback sent from the app is forwarded. Absent keeps it in the
+ * store only, which is still the record; the mail is for whoever reads it.
+ */
+ feedbackTo?: string;
/**
* Serves the built client for anything that is not an API route. Present
* only in a deployment that serves the app and the API together; in
@@ -121,6 +127,12 @@ const CREDENTIAL_ROUTES = new Set([
]);
const CREDENTIAL_BUCKET = { burst: 12, perSecond: 0.2 };
const GENERAL_BUCKET = { burst: 240, perSecond: 40 };
+/*
+ * Feedback, per person rather than per address. The form is one click from
+ * most screens, and a stuck key or a script must not fill the inbox it
+ * forwards to. Five, then one more every twelve minutes.
+ */
+const FEEDBACK_BUCKET = { burst: 5, perSecond: 5 / 3600 };
/*
* This service answers JSON to a known origin and serves no markup of its own,
@@ -307,6 +319,7 @@ export function createApp(options: AppOptions) {
const webOrigin = options.webOrigin ?? allowedOrigins[0] ?? "";
const credentialLimit = rateLimiter(CREDENTIAL_BUCKET);
const generalLimit = rateLimiter(GENERAL_BUCKET);
+ const feedbackLimit = rateLimiter(FEEDBACK_BUCKET);
async function sessionsForMember(membership: Membership, sessions: SessionRecord[]) {
const states = options.sessionLiveness
@@ -1424,6 +1437,29 @@ export function createApp(options: AppOptions) {
return send(response, 201, { comment: result.value });
}
+ /* ---- Feedback ---- */
+
+ if (route === "POST /api/feedback") {
+ const membership = await requireMember(request);
+ if (!membership) return send(response, 401, { error: "sign in first" });
+ const allowance = feedbackLimit.take(membership.uid);
+ if (!allowance.ok) {
+ response.setHeader("Retry-After", String(Math.ceil(allowance.retryAfterMs / 1000)));
+ return send(response, 429, { error: "That is plenty for now. Try again in a little while." });
+ }
+ const result = await submitFeedback(
+ store,
+ mailer,
+ membership,
+ await readBody(request),
+ String(request.headers["user-agent"] ?? ""),
+ options.feedbackTo,
+ log,
+ );
+ if (!result.ok) return send(response, result.status, { error: result.error });
+ return send(response, 201, { feedback: { id: result.value.id, at: result.value.at } });
+ }
+
/* ---- Inbox ---- */
if (route === "GET /api/notifications") {
diff --git a/app/server/import-json.ts b/app/server/import-json.ts
index 9899d18..c4295e8 100644
--- a/app/server/import-json.ts
+++ b/app/server/import-json.ts
@@ -89,6 +89,10 @@ async function main(): Promise {
await copy(`notification ${notification.id}`, () => target.putNotification(notification));
}
+ for (const feedback of await source.feedback(Number.MAX_SAFE_INTEGER)) {
+ await copy(`feedback ${feedback.id}`, () => target.putFeedback(feedback));
+ }
+
console.log(`import: ${copied} records copied from ${file}, ${skipped} already present`);
} finally {
await target.close();
diff --git a/app/server/index.ts b/app/server/index.ts
index 262d2c0..12bdd52 100644
--- a/app/server/index.ts
+++ b/app/server/index.ts
@@ -49,6 +49,7 @@ const server = createAccountsServer({
webOrigin: config.webOrigin,
trustProxy: config.trustProxy,
mailer: createMailer(config.mail),
+ feedbackTo: config.feedbackTo,
serveClient: config.clientDir ? staticFiles(config.clientDir) : undefined,
relay: forward ?? undefined,
sessionLiveness: config.relayUrl ? relaySessionLiveness(config.relayUrl) : undefined,
diff --git a/app/server/lib/config.ts b/app/server/lib/config.ts
index da02a2c..1a1280c 100644
--- a/app/server/lib/config.ts
+++ b/app/server/lib/config.ts
@@ -45,6 +45,11 @@ export interface Config {
* so an unconfigured deployment still creates a perfectly good invite.
*/
mail: { provider?: "sendgrid" | "json"; apiUrl?: string; apiKey?: string; from?: string };
+ /**
+ * Where feedback sent from the app is forwarded by email. Absent keeps it
+ * in the database only, which is still the record.
+ */
+ feedbackTo?: string;
}
export class ConfigError extends Error {}
@@ -169,5 +174,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
apiKey: env.MAIL_API_KEY?.trim() || undefined,
from: env.MAIL_FROM?.trim() || undefined,
},
+ feedbackTo: env.FEEDBACK_TO?.trim() || undefined,
};
}
diff --git a/app/server/lib/mail-feedback.test.ts b/app/server/lib/mail-feedback.test.ts
new file mode 100644
index 0000000..9a2a304
--- /dev/null
+++ b/app/server/lib/mail-feedback.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { feedbackMessage } from "./mail";
+import type { Feedback } from "./types";
+
+const feedback: Feedback = {
+ id: "fbk_1",
+ uid: "uid-1",
+ email: "ana@example.com",
+ orgId: "org_1",
+ kind: "problem",
+ body: "The gate never opened & I gave up.",
+ surface: "session-gate",
+ route: "/sessions",
+ appVersion: "0.15.1",
+ userAgent: "Chrome 129 on macOS",
+ canReply: false,
+ context: { host: "laptop" },
+ at: Date.UTC(2026, 8, 14, 12, 0, 0),
+};
+
+describe("feedbackMessage", () => {
+ it("says what kind of message it is and where it came from", () => {
+ const message = feedbackMessage(feedback, "team@example.com");
+ expect(message.to).toBe("team@example.com");
+ expect(message.subject).toBe("[shell.online feedback] Problem: The gate never opened & I gave up.");
+ expect(message.text).toContain("session-gate on /sessions");
+ expect(message.text).toContain("host: laptop");
+ expect(message.text).toContain("asked not to be written to");
+ });
+
+ it("escapes what the sender typed before it is rendered as HTML", () => {
+ const message = feedbackMessage(feedback, "team@example.com");
+ expect(message.html).not.toContain("never");
+ expect(message.html).toContain("<b>never</b>");
+ expect(message.html).toContain("& I gave up.");
+ });
+
+ it("shortens a long first line in the subject", () => {
+ const message = feedbackMessage({ ...feedback, body: `${"word ".repeat(30)}end` }, "team@example.com");
+ expect(message.subject.length).toBeLessThan(110);
+ expect(message.subject.endsWith("…")).toBe(true);
+ });
+});
diff --git a/app/server/lib/mail.ts b/app/server/lib/mail.ts
index 9fc96a8..839b7bf 100644
--- a/app/server/lib/mail.ts
+++ b/app/server/lib/mail.ts
@@ -1,3 +1,5 @@
+import type { Feedback } from "./types";
+
/**
* Sending an invitation by email.
*
@@ -322,3 +324,67 @@ export function invitationMessage(invitation: Invitation, now = Date.now()): Mes
return { to: invitation.to, subject, html, text };
}
+
+/* ---- Feedback, forwarded to whoever reads it ---- */
+
+const KIND_WORD: Record = {
+ problem: "Problem",
+ idea: "Idea",
+ question: "Question",
+};
+
+/** The first line of a message, short enough for a subject. */
+function summarize(body: string, limit = 60): string {
+ const flat = body.replace(/\s+/g, " ").trim();
+ return flat.length > limit ? `${flat.slice(0, limit).trimEnd()}…` : flat;
+}
+
+/**
+ * One message per piece of feedback, as plain as a forwarded note.
+ *
+ * Everything in it was typed by the sender or attached by their browser, so
+ * all of it is escaped. The reply address is in the body rather than in a
+ * Reply-To header: the sender chose whether to be written back to, and a
+ * header the mail client acts on by itself would make that choice for them.
+ */
+export function feedbackMessage(feedback: Feedback, to: string): Message {
+ const kind = KIND_WORD[feedback.kind];
+ const subject = `[shell.online feedback] ${kind}: ${summarize(feedback.body)}`;
+ const reply = feedback.canReply
+ ? `${feedback.email} said it is fine to reply.`
+ : `${feedback.email} asked not to be written to about this.`;
+ const facts: [string, string][] = [
+ ["From", reply],
+ ["Where", `${feedback.surface} on ${feedback.route || "/"}`],
+ ["App", `${feedback.appVersion || "unknown"} · ${feedback.userAgent || "unknown browser"}`],
+ ["Sent", new Date(feedback.at).toISOString()],
+ ...Object.entries(feedback.context).map(([key, value]): [string, string] => [key, value]),
+ ["Id", feedback.id],
+ ];
+
+ const text = [
+ `${kind} from ${feedback.email}`,
+ "",
+ feedback.body,
+ "",
+ ...facts.map(([key, value]) => `${key}: ${value}`),
+ ].join("\n");
+
+ const rows = facts
+ .map(
+ ([key, value]) =>
+ `
+
+`;
+
+ return { to, subject, html, text };
+}
diff --git a/app/server/lib/migrations/011_feedback.sql b/app/server/lib/migrations/011_feedback.sql
new file mode 100644
index 0000000..3106d61
--- /dev/null
+++ b/app/server/lib/migrations/011_feedback.sql
@@ -0,0 +1,27 @@
+-- What people tell us from inside the app.
+--
+-- One row per message, with where in the app it was written: the control that
+-- opened the form, the route, the build and the browser, and a few facts the
+-- control attached, such as which kind of session was being started. Nothing
+-- from a terminal: the app never has the plaintext, and the form says so.
+--
+-- A message belongs to the service rather than to a team, so deleting the
+-- account that sent it clears who sent it and keeps the words, the same way the
+-- activity trail keeps what was typed.
+CREATE TABLE IF NOT EXISTS feedback (
+ id TEXT PRIMARY KEY,
+ uid TEXT NOT NULL,
+ email TEXT NOT NULL,
+ org_id TEXT,
+ kind TEXT NOT NULL CHECK (kind IN ('problem', 'idea', 'question')),
+ body TEXT NOT NULL,
+ surface TEXT NOT NULL,
+ route TEXT NOT NULL,
+ app_version TEXT NOT NULL,
+ user_agent TEXT NOT NULL,
+ can_reply BOOLEAN NOT NULL DEFAULT FALSE,
+ context JSONB NOT NULL DEFAULT '{}'::jsonb,
+ at BIGINT NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS feedback_at ON feedback (at DESC);
diff --git a/app/server/lib/store-conformance.test.ts b/app/server/lib/store-conformance.test.ts
index 918ec33..712c816 100644
--- a/app/server/lib/store-conformance.test.ts
+++ b/app/server/lib/store-conformance.test.ts
@@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { MemoryStore } from "./store-memory";
import { PostgresStore } from "./store-postgres";
import { DELETED_ACCOUNT_MEMORY_MS, DELETED_ACTOR_EMAIL, type Store } from "./store";
-import type { AgentCommand, AuditEvent, CliToken, Notification, SessionRecord } from "./types";
+import type { AgentCommand, AuditEvent, CliToken, Feedback, Notification, SessionRecord } from "./types";
import type { Invite, Membership, Organization } from "./orgs";
/**
@@ -127,7 +127,27 @@ function notification(overrides: Partial = {}): Notification {
type Implementation = { name: string; open: () => Promise; reset: (store: Store) => Promise };
+function feedback(overrides: Partial = {}): Feedback {
+ return {
+ id: "fbk_1",
+ uid: "uid-1",
+ email: "ana@example.com",
+ orgId: "org_1",
+ kind: "problem",
+ body: "The gate did not open",
+ surface: "session-gate",
+ route: "/sessions",
+ appVersion: "0.15.1",
+ userAgent: "Chrome 129 on macOS",
+ canReply: true,
+ context: { host: "laptop" },
+ at: 1000,
+ ...overrides,
+ };
+}
+
const TABLES = [
+ "feedback",
"deleted_accounts",
"account_keys",
"session_key_shares",
@@ -870,6 +890,22 @@ for (const implementation of implementations) {
});
});
+ describe("feedback", () => {
+ it("keeps a message with where it came from, newest first", async () => {
+ await store.putFeedback(feedback());
+ await store.putFeedback(feedback({ id: "fbk_2", kind: "idea", at: 3000, context: {} }));
+ const listed = await store.feedback();
+ expect(listed.map((entry) => entry.id)).toEqual(["fbk_2", "fbk_1"]);
+ expect(listed[1]).toEqual(feedback());
+ expect(await store.feedback(1)).toHaveLength(1);
+ });
+
+ it("orders messages sent in the same millisecond the same way every time", async () => {
+ for (const id of ["fbk_b", "fbk_c", "fbk_a"]) await store.putFeedback(feedback({ id }));
+ expect((await store.feedback()).map((entry) => entry.id)).toEqual(["fbk_c", "fbk_b", "fbk_a"]);
+ });
+ });
+
describe("claiming an organization on first sight", () => {
/*
* Signing in fires several requests at once. On a new account none of
@@ -991,6 +1027,8 @@ for (const implementation of implementations) {
});
await store.putNotification(notification());
await store.putNotification(notification({ id: "ntf_2", uid: "uid-1", actorUid: "uid-2" }));
+ await store.putFeedback(feedback());
+ await store.putFeedback(feedback({ id: "fbk_bo", uid: "uid-2", email: "bo@example.com" }));
await store.deleteAccount("uid-1", { orgId: "org_1", dissolve: false, successorUid: "uid-3" }, 5000);
@@ -1005,6 +1043,12 @@ for (const implementation of implementations) {
expect(await store.notificationsFor("org_1", "uid-1")).toEqual([]);
expect(await store.notificationsFor("org_1", "uid-2")).toEqual([]);
+ /* Feedback is a message to the service: the words stay, the sender does not. */
+ const messages = await store.feedback();
+ const anas = messages.find((entry) => entry.id === "fbk_1");
+ expect(anas).toMatchObject({ uid: "", email: DELETED_ACTOR_EMAIL, canReply: false, body: "The gate did not open" });
+ expect(messages.find((entry) => entry.id === "fbk_bo")).toMatchObject({ uid: "uid-2", email: "bo@example.com" });
+
const kept = (await store.listOrgSessions("org_1")).find((entry) => entry.id === "s2");
expect(kept?.assigneeUids).toEqual(["uid-3"]);
expect(kept?.assigneeUid).toBe("uid-3");
diff --git a/app/server/lib/store-memory.ts b/app/server/lib/store-memory.ts
index 0e7dd54..6387f72 100644
--- a/app/server/lib/store-memory.ts
+++ b/app/server/lib/store-memory.ts
@@ -18,6 +18,7 @@ import type {
CliToken,
Comment,
Device,
+ Feedback,
Notification,
SessionKeyShare,
SessionRecord,
@@ -65,6 +66,7 @@ interface Shape {
audit: AuditEvent[];
comments: Comment[];
notifications: Notification[];
+ feedback: Feedback[];
accountKeys: AccountKey[];
deletedAccounts: { uid: string; deletedAt: number }[];
teamKeys: TeamKey[];
@@ -74,7 +76,7 @@ interface Shape {
const EMPTY: Shape = {
codes: [], tokens: [], sessions: [], commands: [],
organizations: [], memberships: [], invites: [], audit: [],
- comments: [], notifications: [], accountKeys: [], deletedAccounts: [],
+ comments: [], notifications: [], feedback: [], accountKeys: [], deletedAccounts: [],
teamKeys: [], teamKeyShares: [],
};
@@ -138,6 +140,7 @@ export class MemoryStore implements Store {
audit: parsed.audit ?? [],
comments: parsed.comments ?? [],
notifications: parsed.notifications ?? [],
+ feedback: parsed.feedback ?? [],
accountKeys: parsed.accountKeys ?? [],
deletedAccounts: parsed.deletedAccounts ?? [],
teamKeys: parsed.teamKeys ?? [],
@@ -341,6 +344,13 @@ export class MemoryStore implements Store {
for (const event of data.audit) {
if (event.actorUid === uid) event.actorEmail = DELETED_ACTOR_EMAIL;
}
+ /* The words stay, as a message to the service; who sent them does not. */
+ for (const entry of data.feedback) {
+ if (entry.uid !== uid) continue;
+ entry.uid = "";
+ entry.email = DELETED_ACTOR_EMAIL;
+ entry.canReply = false;
+ }
data.deletedAccounts = [
...data.deletedAccounts.filter((entry) => entry.uid !== uid),
{ uid, deletedAt: now },
@@ -837,6 +847,19 @@ export class MemoryStore implements Store {
return this.data.organizations;
}
+ /* ---- Feedback ---- */
+
+ async putFeedback(feedback: Feedback): Promise {
+ this.data.feedback.push(feedback);
+ this.flush();
+ }
+
+ async feedback(limit = 100): Promise {
+ return [...this.data.feedback]
+ .sort(byTime((entry) => entry.at, (entry) => entry.id, true))
+ .slice(0, limit);
+ }
+
async tokensForImport(): Promise {
return this.data.tokens;
}
diff --git a/app/server/lib/store-postgres.ts b/app/server/lib/store-postgres.ts
index c5068b2..7c5ce01 100644
--- a/app/server/lib/store-postgres.ts
+++ b/app/server/lib/store-postgres.ts
@@ -20,6 +20,7 @@ import type {
CliToken,
Comment,
Device,
+ Feedback,
Notification,
SessionKeyShare,
SessionRecord,
@@ -315,6 +316,24 @@ function toNotification(row: Row): Notification {
}) as unknown as Notification;
}
+function toFeedback(row: Row): Feedback {
+ return {
+ id: row.id as string,
+ uid: row.uid as string,
+ email: row.email as string,
+ ...(row.org_id ? { orgId: row.org_id as string } : {}),
+ kind: row.kind as Feedback["kind"],
+ body: row.body as string,
+ surface: row.surface as string,
+ route: row.route as string,
+ appVersion: row.app_version as string,
+ userAgent: row.user_agent as string,
+ canReply: row.can_reply as boolean,
+ context: (row.context ?? {}) as Record,
+ at: row.at as number,
+ };
+}
+
/**
* The production store.
*
@@ -992,6 +1011,11 @@ export class PostgresStore implements Store {
uid,
DELETED_ACTOR_EMAIL,
]);
+ /* The words stay, as a message to the service; who sent them does not. */
+ await client.query("UPDATE feedback SET uid = '', email = $2, can_reply = FALSE WHERE uid = $1", [
+ uid,
+ DELETED_ACTOR_EMAIL,
+ ]);
await client.query(
`INSERT INTO deleted_accounts (uid, deleted_at) VALUES ($1, $2)
ON CONFLICT (uid) DO UPDATE SET deleted_at = EXCLUDED.deleted_at`,
@@ -1519,6 +1543,38 @@ export class PostgresStore implements Store {
return result.rowCount ?? 0;
}
+ /* ---- Feedback ---- */
+
+ async putFeedback(feedback: Feedback): Promise {
+ await this.pool.query(
+ `INSERT INTO feedback (id, uid, email, org_id, kind, body, surface, route, app_version, user_agent, can_reply, context, at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
+ [
+ feedback.id,
+ feedback.uid,
+ feedback.email,
+ feedback.orgId ?? null,
+ feedback.kind,
+ feedback.body,
+ feedback.surface,
+ feedback.route,
+ feedback.appVersion,
+ feedback.userAgent,
+ feedback.canReply,
+ JSON.stringify(feedback.context),
+ feedback.at,
+ ],
+ );
+ }
+
+ async feedback(limit = 100): Promise {
+ const rows = await this.rows(
+ 'SELECT * FROM feedback ORDER BY at DESC, id COLLATE "C" DESC LIMIT $1',
+ [limit],
+ );
+ return rows.map(toFeedback);
+ }
+
/* ---- Housekeeping ---- */
async purgeExpired(now = Date.now()): Promise {
diff --git a/app/server/lib/store.ts b/app/server/lib/store.ts
index 100f5c2..0545cfc 100644
--- a/app/server/lib/store.ts
+++ b/app/server/lib/store.ts
@@ -7,6 +7,7 @@ import type {
CliToken,
Comment,
Device,
+ Feedback,
Notification,
SessionKeyShare,
SessionRecord,
@@ -229,6 +230,14 @@ export interface Store {
markNotificationRead(orgId: string, uid: string, id: string, now?: number): Promise;
markAllNotificationsRead(orgId: string, uid: string, now?: number): Promise;
+ /* ---- Feedback ---- */
+ putFeedback(feedback: Feedback): Promise;
+ /**
+ * Newest first. For an operator's export and for tests; the app never lists
+ * it. Deleting an account keeps its messages and clears who sent them.
+ */
+ feedback(limit?: number): Promise;
+
/* ---- Housekeeping ---- */
purgeExpired(now?: number): Promise;
close(): Promise;
diff --git a/app/server/lib/types.ts b/app/server/lib/types.ts
index 85604e8..b301cc4 100644
--- a/app/server/lib/types.ts
+++ b/app/server/lib/types.ts
@@ -169,6 +169,33 @@ export interface Notification {
readAt?: number;
}
+/**
+ * Something a person told us from inside the app.
+ *
+ * Kept as it was sent, with where in the app it was written, so a report can
+ * be read without asking the reporter which screen they meant. Nothing from a
+ * terminal is in it: the app never has the plaintext, and the form says so.
+ */
+export interface Feedback {
+ id: string;
+ uid: string;
+ email: string;
+ orgId?: string;
+ kind: "problem" | "idea" | "question";
+ body: string;
+ /** Which control opened the form: "new-session", "session-gate", and so on. */
+ surface: string;
+ /** The app route it was sent from, path only. */
+ route: string;
+ appVersion: string;
+ userAgent: string;
+ /** Whether the sender is happy to be written to about it. */
+ canReply: boolean;
+ /** A few facts the surface attached, such as the kind of session being started. */
+ context: Record;
+ at: number;
+}
+
export interface SessionRecord {
id: string;
uid: string;
diff --git a/app/server/routes/feedback.test.ts b/app/server/routes/feedback.test.ts
new file mode 100644
index 0000000..49e4c1c
--- /dev/null
+++ b/app/server/routes/feedback.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from "vitest";
+import { MemoryStore } from "../lib/store-memory";
+import type { Membership } from "../lib/orgs";
+import type { Message } from "../lib/mail";
+import { MAX_FEEDBACK, readFeedback, submitFeedback } from "./feedback";
+
+const ana: Membership = {
+ orgId: "org_1",
+ uid: "uid-1",
+ email: "ana@example.com",
+ name: "Ana",
+ role: "owner",
+ joinedAt: 1000,
+};
+
+const valid = {
+ kind: "idea",
+ body: " Let me pin a session. ",
+ surface: "account-menu",
+ route: "/account?tab=vault#nope",
+ app_version: "0.15.1",
+ can_reply: true,
+ context: { host: "laptop", count: 3, "Bad Key": "x", empty: " " },
+};
+
+function mailbox() {
+ const sent: Message[] = [];
+ return { sent, mailer: { send: async (message: Message) => void sent.push(message) } };
+}
+
+describe("readFeedback", () => {
+ it("keeps the message and the place it came from, and nothing else", () => {
+ const read = readFeedback(valid);
+ expect(read.ok).toBe(true);
+ if (!read.ok) return;
+ expect(read.value).toEqual({
+ kind: "idea",
+ body: "Let me pin a session.",
+ surface: "account-menu",
+ route: "/account",
+ appVersion: "0.15.1",
+ canReply: true,
+ context: { host: "laptop" },
+ });
+ });
+
+ it("needs a kind it knows", () => {
+ const read = readFeedback({ ...valid, kind: "rant" });
+ expect(read).toMatchObject({ ok: false, status: 400 });
+ });
+
+ it("needs something written", () => {
+ const read = readFeedback({ ...valid, body: " " });
+ expect(read).toMatchObject({ ok: false, status: 400, error: "write something first" });
+ });
+
+ it("caps the length", () => {
+ const read = readFeedback({ ...valid, body: "x".repeat(MAX_FEEDBACK + 1) });
+ expect(read).toMatchObject({ ok: false, status: 400 });
+ expect(readFeedback({ ...valid, body: "x".repeat(MAX_FEEDBACK) }).ok).toBe(true);
+ });
+
+ it("keeps the attached facts small", () => {
+ const context: Record = {};
+ for (let index = 0; index < 12; index += 1) context[`key_${index}`] = "v".repeat(500);
+ const read = readFeedback({ ...valid, context });
+ if (!read.ok) throw new Error("expected ok");
+ expect(Object.keys(read.value.context)).toHaveLength(8);
+ expect(read.value.context.key_0).toHaveLength(200);
+ });
+
+ it("names an unknown surface rather than trusting one", () => {
+ const read = readFeedback({ ...valid, surface: "Weird Surface!" });
+ if (!read.ok) throw new Error("expected ok");
+ expect(read.value.surface).toBe("unknown");
+ });
+
+ it("treats a missing reply choice as no", () => {
+ const read = readFeedback({ ...valid, can_reply: "yes" });
+ if (!read.ok) throw new Error("expected ok");
+ expect(read.value.canReply).toBe(false);
+ });
+});
+
+describe("submitFeedback", () => {
+ it("stores the message and forwards it when an address is set", async () => {
+ const store = MemoryStore.memory();
+ const { sent, mailer } = mailbox();
+ const log = vi.fn();
+ const result = await submitFeedback(store, mailer, ana, valid, "Mozilla/5.0 Chrome/129", "team@example.com", log, 5000);
+ expect(result.ok).toBe(true);
+ const [kept] = await store.feedback();
+ expect(kept).toMatchObject({
+ uid: "uid-1",
+ email: "ana@example.com",
+ orgId: "org_1",
+ kind: "idea",
+ body: "Let me pin a session.",
+ surface: "account-menu",
+ route: "/account",
+ userAgent: "Mozilla/5.0 Chrome/129",
+ canReply: true,
+ context: { host: "laptop" },
+ at: 5000,
+ });
+ expect(kept.id).toMatch(/^fbk_/);
+ expect(sent).toHaveLength(1);
+ expect(sent[0].to).toBe("team@example.com");
+ expect(sent[0].subject).toContain("Idea");
+ expect(sent[0].text).toContain("ana@example.com");
+ expect(sent[0].text).toContain("fine to reply");
+ expect(log).not.toHaveBeenCalled();
+ });
+
+ it("stores without forwarding when no address is set", async () => {
+ const store = MemoryStore.memory();
+ const { sent, mailer } = mailbox();
+ await submitFeedback(store, mailer, ana, valid, "", undefined, vi.fn());
+ expect(await store.feedback()).toHaveLength(1);
+ expect(sent).toEqual([]);
+ });
+
+ it("keeps the message when the mail provider fails", async () => {
+ const store = MemoryStore.memory();
+ const log = vi.fn();
+ const mailer = { send: async () => { throw new Error("sendgrid returned 401"); } };
+ const result = await submitFeedback(store, mailer, ana, valid, "", "team@example.com", log);
+ expect(result.ok).toBe(true);
+ expect(await store.feedback()).toHaveLength(1);
+ expect(log).toHaveBeenCalledOnce();
+ });
+
+ it("refuses what it cannot read without writing anything", async () => {
+ const store = MemoryStore.memory();
+ const result = await submitFeedback(store, mailbox().mailer, ana, { kind: "idea", body: "" }, "", undefined, vi.fn());
+ expect(result).toMatchObject({ ok: false, status: 400 });
+ expect(await store.feedback()).toEqual([]);
+ });
+});
diff --git a/app/server/routes/feedback.ts b/app/server/routes/feedback.ts
new file mode 100644
index 0000000..8f000dd
--- /dev/null
+++ b/app/server/routes/feedback.ts
@@ -0,0 +1,136 @@
+import type { Feedback, Store } from "../lib/store";
+import { newId, type Membership } from "../lib/orgs";
+import { feedbackMessage, type Mailer } from "../lib/mail";
+import type { Outcome } from "./social";
+
+export const MAX_FEEDBACK = 4000;
+export const FEEDBACK_KINDS = ["problem", "idea", "question"] as const;
+export type FeedbackKind = (typeof FEEDBACK_KINDS)[number];
+
+/* How much of the app's own bookkeeping one message may carry. */
+const MAX_ROUTE = 200;
+const MAX_VERSION = 40;
+const MAX_USER_AGENT = 300;
+const MAX_CONTEXT_ENTRIES = 8;
+const MAX_CONTEXT_VALUE = 200;
+
+/** What the browser sends: the message, and where in the app it was written. */
+export interface FeedbackInput {
+ kind: FeedbackKind;
+ body: string;
+ surface: string;
+ route: string;
+ appVersion: string;
+ canReply: boolean;
+ context: Record;
+}
+
+function isKind(value: unknown): value is FeedbackKind {
+ return typeof value === "string" && (FEEDBACK_KINDS as readonly string[]).includes(value);
+}
+
+/*
+ * The route the message came from, path only. A query string can name a
+ * session (?open=...) and a hash is where a share link keeps its key, so
+ * neither is kept even when the client sends them.
+ */
+function readRoute(value: unknown): string {
+ if (typeof value !== "string") return "";
+ const path = value.split(/[?#]/, 1)[0].trim();
+ return path.startsWith("/") ? path.slice(0, MAX_ROUTE) : "";
+}
+
+/*
+ * Small facts the surface attached: which kind of session was being started,
+ * which machine, what the error said. Bounded in count and size, and keyed by
+ * identifiers rather than free text, so the column stays a handful of labels
+ * and never becomes a second message body.
+ */
+function readContext(value: unknown): Record {
+ const context: Record = {};
+ if (typeof value !== "object" || value === null) return context;
+ let kept = 0;
+ for (const [key, raw] of Object.entries(value as Record)) {
+ if (kept >= MAX_CONTEXT_ENTRIES) break;
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || typeof raw !== "string") continue;
+ const trimmed = raw.trim();
+ if (!trimmed) continue;
+ context[key] = trimmed.slice(0, MAX_CONTEXT_VALUE);
+ kept += 1;
+ }
+ return context;
+}
+
+export function readFeedback(body: unknown): Outcome {
+ const given = (typeof body === "object" && body !== null ? body : {}) as Record;
+ if (!isKind(given.kind)) {
+ return { ok: false, status: 400, error: "say whether this is a problem, an idea or a question" };
+ }
+ const text = typeof given.body === "string" ? given.body.trim() : "";
+ if (!text) return { ok: false, status: 400, error: "write something first" };
+ if (text.length > MAX_FEEDBACK) {
+ return {
+ ok: false,
+ status: 400,
+ error: `keep it under ${MAX_FEEDBACK} characters; you can send more than one`,
+ };
+ }
+ /* The surface is an identifier the app chose, so anything else is "unknown" rather than kept. */
+ const surface =
+ typeof given.surface === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(given.surface)
+ ? given.surface
+ : "unknown";
+ return {
+ ok: true,
+ value: {
+ kind: given.kind,
+ body: text,
+ surface,
+ route: readRoute(given.route),
+ appVersion:
+ typeof given.app_version === "string" ? given.app_version.trim().slice(0, MAX_VERSION) : "",
+ canReply: given.can_reply === true,
+ context: readContext(given.context),
+ },
+ };
+}
+
+/**
+ * Keeps a message and, when an address is configured, forwards it.
+ *
+ * The row is the record; the email is a convenience for whoever reads them.
+ * So the mail is best effort, like an invitation: a provider having a bad
+ * afternoon must not turn a message somebody took the time to write into an
+ * error they have to read.
+ */
+export async function submitFeedback(
+ store: Store,
+ mailer: Mailer,
+ sender: Membership,
+ body: unknown,
+ userAgent: string,
+ forwardTo: string | undefined,
+ log: (message: string, error?: unknown) => void,
+ now = Date.now(),
+): Promise> {
+ const read = readFeedback(body);
+ if (!read.ok) return read;
+ const feedback: Feedback = {
+ id: newId("fbk"),
+ uid: sender.uid,
+ email: sender.email,
+ orgId: sender.orgId,
+ ...read.value,
+ userAgent: userAgent.trim().slice(0, MAX_USER_AGENT),
+ at: now,
+ };
+ await store.putFeedback(feedback);
+ if (forwardTo) {
+ try {
+ await mailer.send(feedbackMessage(feedback, forwardTo));
+ } catch (error) {
+ log(`accounts: could not forward feedback ${feedback.id}`, error);
+ }
+ }
+ return { ok: true, value: feedback };
+}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 20f085e..a979ee8 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -16,6 +16,7 @@ import { Audit } from "./routes/Audit";
import { CliAuthorize } from "./routes/CliAuthorize";
import { VaultProvider } from "./vault/VaultProvider";
import { TeamKeyProvider } from "./vault/TeamKeyProvider";
+import { FeedbackProvider } from "./feedback/FeedbackProvider";
export default function App() {
return (
@@ -23,6 +24,7 @@ export default function App() {
+ } />
} />
} />
+
diff --git a/app/src/components/AppShell.tsx b/app/src/components/AppShell.tsx
index d8b070a..4bb677c 100644
--- a/app/src/components/AppShell.tsx
+++ b/app/src/components/AppShell.tsx
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import {
- Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning,
+ Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning, ChatCircleDots,
} from "@phosphor-icons/react";
import { Inbox } from "./Inbox";
import { Avatar } from "./Avatar";
import { Wordmark } from "./Wordmark";
import { useAuth } from "../auth/AuthProvider";
+import { useFeedback } from "../feedback/context";
import { COPY_FAILED, useCopy } from "../lib/clipboard";
const NAV = [
@@ -52,6 +53,7 @@ function AccountMenu() {
const [open, setOpen] = useState(false);
const wrapper = useRef(null);
const navigate = useNavigate();
+ const feedback = useFeedback();
useEffect(() => {
if (!open) return;
@@ -96,6 +98,18 @@ function AccountMenu() {
Account
+ {/* Always one click away, from the rail and from the phone's top bar alike. */}
+
+ {/* The one moment a reason is on the tip of the tongue. */}
+
+
+ Tell us why you are leaving
+
+
);
}
diff --git a/app/src/components/NewSessionModal.tsx b/app/src/components/NewSessionModal.tsx
index 95f5eda..d34e358 100644
--- a/app/src/components/NewSessionModal.tsx
+++ b/app/src/components/NewSessionModal.tsx
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { X, Warning } from "@phosphor-icons/react";
import { Button } from "./Button";
import { Alert } from "./Alert";
+import { FeedbackLink } from "../feedback/FeedbackLink";
import {
SESSION_KINDS,
sessionName,
@@ -320,6 +321,28 @@ export function NewSessionModal({
No linked machine is available. Run shell login on one.
)}
+
+ {/*
+ * Under the buttons, where somebody who could not start a session
+ * ends up. The facts a report needs most go with it: what was being
+ * started, on which machine, whether it was reachable, and what the
+ * form said if it refused.
+ */}
+
+
+ Something off with starting a session? Tell us
+
+
diff --git a/app/src/components/SignedInModal.tsx b/app/src/components/SignedInModal.tsx
index 67651e1..d8d9831 100644
--- a/app/src/components/SignedInModal.tsx
+++ b/app/src/components/SignedInModal.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { CheckCircle } from "@phosphor-icons/react";
import { Button } from "./Button";
+import { FeedbackLink } from "../feedback/FeedbackLink";
interface SignedInModalProps {
onClose(): void;
@@ -59,6 +60,13 @@ export function SignedInModal({ onClose }: SignedInModalProps) {
+ {/* The first thing a new machine's owner sees; the first place to hear about it. */}
+
+ Anything confusing about linking? Tell us
+
,
diff --git a/app/src/feedback/FeedbackLink.tsx b/app/src/feedback/FeedbackLink.tsx
new file mode 100644
index 0000000..39d3b2f
--- /dev/null
+++ b/app/src/feedback/FeedbackLink.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from "react";
+import { ChatCircleDots } from "@phosphor-icons/react";
+import { useFeedback, type FeedbackRequest } from "./context";
+
+interface FeedbackLinkProps extends FeedbackRequest {
+ children?: ReactNode;
+ className?: string;
+}
+
+/**
+ * The line that opens the feedback sheet, from wherever something might have
+ * gone wrong. Quiet on purpose: it sits under a form or a notice and must not
+ * compete with the action the person came for.
+ */
+export function FeedbackLink({
+ children = "Something off? Tell us",
+ className,
+ ...request
+}: FeedbackLinkProps) {
+ const { open } = useFeedback();
+ return (
+
+ );
+}
diff --git a/app/src/feedback/FeedbackProvider.tsx b/app/src/feedback/FeedbackProvider.tsx
new file mode 100644
index 0000000..05b80b7
--- /dev/null
+++ b/app/src/feedback/FeedbackProvider.tsx
@@ -0,0 +1,39 @@
+import { useCallback, useMemo, useState, type ReactNode } from "react";
+import { useLocation } from "react-router-dom";
+import { useAuth } from "../auth/AuthProvider";
+import { sendFeedback } from "../lib/api";
+import { routeForFeedback } from "../lib/feedback";
+import { FeedbackContext, type FeedbackRequest } from "./context";
+import { FeedbackSheet } from "./FeedbackSheet";
+
+/**
+ * One sheet for the whole app, opened from wherever a FeedbackLink sits.
+ *
+ * Rendered here rather than by the link that opened it, so it outlives a
+ * modal that closes underneath it and reads the same on every screen. It
+ * needs a signed-in person: the message is recorded against the account,
+ * which is what makes a reply possible.
+ */
+export function FeedbackProvider({ children }: { children: ReactNode }) {
+ const { user } = useAuth();
+ const location = useLocation();
+ const [request, setRequest] = useState(null);
+ const open = useCallback((next: FeedbackRequest) => setRequest(next), []);
+ const close = useCallback(() => setRequest(null), []);
+ const value = useMemo(() => ({ open }), [open]);
+
+ return (
+
+ {children}
+ {request && user && (
+
+ )}
+
+ );
+}
diff --git a/app/src/feedback/FeedbackSheet.tsx b/app/src/feedback/FeedbackSheet.tsx
new file mode 100644
index 0000000..04c9c1c
--- /dev/null
+++ b/app/src/feedback/FeedbackSheet.tsx
@@ -0,0 +1,234 @@
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { createPortal } from "react-dom";
+import { CheckCircle, X } from "@phosphor-icons/react";
+import { Button } from "../components/Button";
+import { Alert } from "../components/Alert";
+import {
+ APP_VERSION,
+ FEEDBACK_KINDS,
+ MAX_FEEDBACK,
+ describeBrowser,
+ surfaceLabel,
+ trimContext,
+ type FeedbackKind,
+ type FeedbackPayload,
+} from "../lib/feedback";
+import type { FeedbackRequest } from "./context";
+
+export interface FeedbackSheetProps {
+ /** Whose message it is, shown next to the reply choice so nothing is implied. */
+ email: string;
+ /** The route it is sent from, path only. */
+ route: string;
+ request: FeedbackRequest;
+ /** Defaults to this browser's. A parameter so the sheet can be rendered elsewhere. */
+ userAgent?: string;
+ onSend(payload: FeedbackPayload): Promise;
+ onClose(): void;
+}
+
+/**
+ * The form itself: a kind, a message, what travels with it, and whether a
+ * reply is welcome.
+ *
+ * Everything sent is on the screen. The "sent with your message" list is the
+ * actual context, not a summary of it, so nobody has to wonder what the app
+ * attached on their behalf. Nothing from a terminal is ever in it: the app
+ * never has the plaintext, and the line under the list says so.
+ */
+export function FeedbackSheet({
+ email,
+ route,
+ request,
+ userAgent = navigator.userAgent,
+ onSend,
+ onClose,
+}: FeedbackSheetProps) {
+ const [kind, setKind] = useState(request.kind ?? "problem");
+ const [text, setText] = useState("");
+ const [canReply, setCanReply] = useState(true);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [sent, setSent] = useState(false);
+ const box = useRef(null);
+ const done = useRef(null);
+ const context = trimContext(request.context);
+ const option = FEEDBACK_KINDS.find((candidate) => candidate.id === kind) ?? FEEDBACK_KINDS[0];
+
+ useEffect(() => {
+ /*
+ * Capture phase, so Escape closes this sheet and stops there. It opens on
+ * top of other dialogs, which listen for the same key on the document,
+ * and one press must not close the form underneath as well.
+ */
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key !== "Escape") return;
+ event.stopPropagation();
+ onClose();
+ };
+ document.addEventListener("keydown", onKey, true);
+ /* Restoring the literal earlier value, not "", so a nested open is safe. */
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.removeEventListener("keydown", onKey, true);
+ document.body.style.overflow = previous;
+ };
+ }, [onClose]);
+
+ /* The box first, then the one button left once the message has gone. */
+ useEffect(() => {
+ const frame = requestAnimationFrame(() => (sent ? done.current : box.current)?.focus());
+ return () => cancelAnimationFrame(frame);
+ }, [sent]);
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ const body = text.trim();
+ if (!body || busy) return;
+ setBusy(true);
+ setError("");
+ try {
+ await onSend({
+ kind,
+ body,
+ surface: request.surface,
+ route,
+ app_version: APP_VERSION,
+ can_reply: canReply,
+ context,
+ });
+ setSent(true);
+ } catch (caught) {
+ setError(caught instanceof Error ? caught.message : "Could not send that. Try again.");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return createPortal(
+
+ {canReply ? (
+ <>
+ If we have a question, we will write to {email}.
+ >
+ ) : (
+ "We will read it and, as you asked, not write back."
+ )}
+
+
+
+ ) : (
+ <>
+
+
Tell us
+
+
+
+
+ >
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/app/src/feedback/context.ts b/app/src/feedback/context.ts
new file mode 100644
index 0000000..a5a3684
--- /dev/null
+++ b/app/src/feedback/context.ts
@@ -0,0 +1,27 @@
+import { createContext, useContext } from "react";
+import type { FeedbackKind } from "../lib/feedback";
+
+export interface FeedbackRequest {
+ /** Which control opened the sheet. Recorded with the message. */
+ surface: string;
+ /** Preselected, so a link under an error opens on "Something broke". */
+ kind?: FeedbackKind;
+ /** A line above the box that says what this surface is asking about. */
+ prompt?: string;
+ /** Small facts about the moment, shown in the sheet and sent with it. */
+ context?: Record;
+}
+
+export interface FeedbackValue {
+ open(request: FeedbackRequest): void;
+}
+
+/*
+ * A no-op by default, so a link rendered somewhere without the provider, such
+ * as a test or a preview, is inert rather than an error.
+ */
+export const FeedbackContext = createContext({ open() {} });
+
+export function useFeedback(): FeedbackValue {
+ return useContext(FeedbackContext);
+}
diff --git a/app/src/lib/api.ts b/app/src/lib/api.ts
index eb89e75..5815d24 100644
--- a/app/src/lib/api.ts
+++ b/app/src/lib/api.ts
@@ -1,4 +1,5 @@
import { auth } from "./firebase";
+import type { FeedbackPayload } from "./feedback";
export { machineOnline } from "./agent";
@@ -210,6 +211,14 @@ export function markNotifications(id?: string) {
});
}
+/** A message from the feedback sheet. The service keeps it and may forward it. */
+export function sendFeedback(input: FeedbackPayload) {
+ return request<{ feedback: { id: string; at: number } }>("/api/feedback", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
export interface SessionRecord {
id: string;
shareUrl: string;
diff --git a/app/src/lib/feedback.test.ts b/app/src/lib/feedback.test.ts
new file mode 100644
index 0000000..72a52f0
--- /dev/null
+++ b/app/src/lib/feedback.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { APP_VERSION, describeBrowser, routeForFeedback, trimContext } from "./feedback";
+
+describe("describeBrowser", () => {
+ it("names the browser and the system, in that order", () => {
+ expect(
+ describeBrowser(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
+ ),
+ ).toBe("Chrome 129 on macOS");
+ });
+
+ it("tells Edge from the Chrome it is built on", () => {
+ expect(
+ describeBrowser(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
+ ),
+ ).toBe("Edge 129 on Windows");
+ });
+
+ it("tells Safari from the Safari token every WebKit browser carries", () => {
+ expect(
+ describeBrowser(
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",
+ ),
+ ).toBe("Safari 17 on iOS");
+ });
+
+ it("says so when it does not know", () => {
+ expect(describeBrowser("curl/8.4.0")).toBe("Unknown browser");
+ });
+});
+
+describe("routeForFeedback", () => {
+ it("keeps the path and drops what could name a session or carry a key", () => {
+ expect(routeForFeedback("/sessions?open=s1#k3y")).toBe("/sessions");
+ expect(routeForFeedback("/account")).toBe("/account");
+ expect(routeForFeedback("")).toBe("/");
+ });
+});
+
+describe("trimContext", () => {
+ it("keeps only facts with something in them", () => {
+ expect(trimContext({ host: " laptop ", empty: " ", missing: undefined })).toEqual({ host: "laptop" });
+ expect(trimContext()).toEqual({});
+ });
+});
+
+describe("APP_VERSION", () => {
+ it("has a value wherever nothing stamped one", () => {
+ expect(APP_VERSION).toBe("dev");
+ });
+});
diff --git a/app/src/lib/feedback.ts b/app/src/lib/feedback.ts
new file mode 100644
index 0000000..30bdb95
--- /dev/null
+++ b/app/src/lib/feedback.ts
@@ -0,0 +1,138 @@
+/*
+ * The feedback sheet's vocabulary, and the little that travels with a message.
+ *
+ * Pure on purpose: nothing here touches the network or the account, so it can
+ * be tested without a browser and rendered outside the app.
+ */
+
+export type FeedbackKind = "problem" | "idea" | "question";
+
+export const MAX_FEEDBACK = 4000;
+
+export interface KindOption {
+ id: FeedbackKind;
+ label: string;
+ placeholder: string;
+}
+
+export const FEEDBACK_KINDS: readonly KindOption[] = [
+ {
+ id: "problem",
+ label: "Something broke",
+ placeholder: "What were you doing, and what happened instead?",
+ },
+ {
+ id: "idea",
+ label: "An idea",
+ placeholder: "What would you change, and what would it save you?",
+ },
+ {
+ id: "question",
+ label: "A question",
+ placeholder: "What are you trying to work out?",
+ },
+];
+
+/** What is sent, in the shape the service reads. */
+export interface FeedbackPayload {
+ kind: FeedbackKind;
+ body: string;
+ surface: string;
+ route: string;
+ app_version: string;
+ can_reply: boolean;
+ context: Record;
+}
+
+/*
+ * Where in the app a message was written, said the way the person reading it
+ * would say it. The id is what is stored; the sentence is what the sheet shows
+ * so the sender can see what "from" will mean.
+ */
+const SURFACE_LABELS: Record = {
+ "account-menu": "the account menu",
+ account: "the Account page",
+ "new-session": "the new session form",
+ "signed-in": "the terminal-linked notice",
+ "session-gate": "the session password gate",
+ "session-ended": "a session that ended",
+ "vault-setup": "vault setup",
+ "vault-unlock": "the vault unlock screen",
+ "vault-error": "a vault that could not be reached",
+ "first-run": "the empty sessions list",
+ "sessions-error": "an error on the sessions list",
+ "session-error": "an error on a session page",
+ "machines-error": "an error on the machines list",
+ "delete-account": "the delete account form",
+};
+
+export function surfaceLabel(surface: string): string {
+ return SURFACE_LABELS[surface] ?? surface;
+}
+
+/*
+ * The version vite.config.ts stamps into the bundle, so a message can say
+ * which build it came from. "dev" wherever nothing stamped it, which is the
+ * dev server and the tests.
+ */
+export const APP_VERSION: string =
+ typeof __SHELL_ONLINE_VERSION__ === "string" ? __SHELL_ONLINE_VERSION__ : "dev";
+
+/*
+ * "Chrome 129 on macOS": enough to know which browser a report is about, and
+ * nothing that would tell two people on the same browser apart. Edge and
+ * Chrome both carry "Chrome/", and both carry "Safari/", so the order of the
+ * checks is the whole trick.
+ */
+export function describeBrowser(userAgent: string): string {
+ const system = /iPhone|iPad/.test(userAgent)
+ ? "iOS"
+ : /Android/.test(userAgent)
+ ? "Android"
+ : /Mac OS X/.test(userAgent)
+ ? "macOS"
+ : /Windows/.test(userAgent)
+ ? "Windows"
+ : /CrOS/.test(userAgent)
+ ? "ChromeOS"
+ : /Linux/.test(userAgent)
+ ? "Linux"
+ : "";
+ const browsers: [string, RegExp][] = [
+ ["Edge", /Edg(?:e|A|iOS)?\/(\d+)/],
+ ["Firefox", /(?:Firefox|FxiOS)\/(\d+)/],
+ ["Chrome", /(?:Chrome|CriOS)\/(\d+)/],
+ ["Safari", /Version\/(\d+)[.\d]* .*Safari/],
+ ];
+ let browser = "Unknown browser";
+ for (const [name, pattern] of browsers) {
+ const match = userAgent.match(pattern);
+ if (match) {
+ browser = `${name} ${match[1]}`;
+ break;
+ }
+ }
+ return system ? `${browser} on ${system}` : browser;
+}
+
+/*
+ * Path only. A query string can name a session (?open=...) and a hash is
+ * where a share link keeps its key, so neither leaves the browser.
+ */
+export function routeForFeedback(pathname: string): string {
+ const path = pathname.split(/[?#]/, 1)[0];
+ return path.startsWith("/") ? path : "/";
+}
+
+/*
+ * Drops facts that are empty, so the "sent with your message" list shows
+ * exactly what is sent and nothing that reads as a blank.
+ */
+export function trimContext(context: Record = {}): Record {
+ const kept: Record = {};
+ for (const [key, value] of Object.entries(context)) {
+ const trimmed = value?.trim();
+ if (trimmed) kept[key] = trimmed.slice(0, 200);
+ }
+ return kept;
+}
diff --git a/app/src/main.tsx b/app/src/main.tsx
index 782da3e..ddb179c 100644
--- a/app/src/main.tsx
+++ b/app/src/main.tsx
@@ -11,6 +11,7 @@ import "./styles/collab.css";
import "./styles/audit.css";
import "./styles/terms.css";
import "./styles/vault.css";
+import "./styles/feedback.css";
createRoot(document.getElementById("root")!).render(
diff --git a/app/src/routes/Account.tsx b/app/src/routes/Account.tsx
index ba16092..52022fa 100644
--- a/app/src/routes/Account.tsx
+++ b/app/src/routes/Account.tsx
@@ -1,6 +1,6 @@
import { useState } from "react";
import { Link } from "react-router-dom";
-import { SealCheck, SignOut, Trash, Warning } from "@phosphor-icons/react";
+import { ChatCircleDots, SealCheck, SignOut, Trash, Warning } from "@phosphor-icons/react";
import { AppShell } from "../components/AppShell";
import { DeleteAccount } from "../components/DeleteAccount";
import { Button } from "../components/Button";
@@ -11,11 +11,13 @@ import { authErrorMessage } from "../lib/auth-errors";
import { useVault } from "../vault/VaultProvider";
import { VaultSetup } from "../vault/VaultGate";
import { VaultPanel } from "../vault/VaultPanel";
+import { useFeedback } from "../feedback/context";
export function Account() {
usePageTitle("Account");
const { user, resendVerification, signOutUser } = useAuth();
const vault = useVault();
+ const feedback = useFeedback();
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@@ -141,6 +143,10 @@ export function Account() {
Reset vault
)}
+