diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml
index 1103d2a1b..38a844512 100644
--- a/.github/workflows/release-desktop.yml
+++ b/.github/workflows/release-desktop.yml
@@ -19,6 +19,9 @@ name: Release Desktop
# - Push of a `desktop-v*` tag builds all platforms.
#
# Required repo secrets:
+# Telegram feedback:
+# ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN dedicated feedback bot token
+# ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID numeric channel ID or @username
# macOS:
# MAC_CSC_LINK base64 of a .p12 containing the Developer
# ID Application cert + private key
@@ -124,6 +127,12 @@ jobs:
ANTSEED_COMPARABLE_PRICES_URL: ${{ vars.ANTSEED_COMPARABLE_PRICES_URL }}
run: node scripts/bake-comparable-prices-url.mjs --require
+ - name: Bake Telegram feedback configuration
+ env:
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }}
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }}
+ run: node scripts/bake-feedback-telegram-config.mjs --require
+
- name: Build monorepo (tiers 0-3)
run: |
pnpm run build:tier0
@@ -221,6 +230,13 @@ jobs:
run: node scripts/bake-comparable-prices-url.mjs --require
shell: pwsh
+ - name: Bake Telegram feedback configuration
+ env:
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }}
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }}
+ run: node scripts/bake-feedback-telegram-config.mjs --require
+ shell: pwsh
+
- name: Build monorepo (tiers 0-3)
run: |
pnpm run build:tier0
@@ -340,6 +356,12 @@ jobs:
ANTSEED_COMPARABLE_PRICES_URL: ${{ vars.ANTSEED_COMPARABLE_PRICES_URL }}
run: node scripts/bake-comparable-prices-url.mjs --require
+ - name: Bake Telegram feedback configuration
+ env:
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }}
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }}
+ run: node scripts/bake-feedback-telegram-config.mjs --require
+
- name: Build monorepo (tiers 0-3)
run: |
pnpm run build:tier0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72ac05d78..baa58898c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,8 @@ This project uses selective package publishing. Each release entry lists the pub
### Added
+- Desktop now includes an Emdash-inspired feedback modal in Help & Support, with optional contact email, image attachments, and opt-in privacy-redacted diagnostic logs delivered to the AntSeed Telegram feedback channel.
+
- Added `@antseed/web-sdk`, a browser buyer SDK: connects to unmodified sellers over WebRTC DataChannels (signaled through a relay) and runs the shared `@antseed/buyer-core` request/payment stack — 402 negotiation, in-browser EIP-712 ReserveAuth/SpendingAuth signing, SSE streaming, and chunked uploads. `AntseedWebClient.create()` durably commits complete channel recovery state to IndexedDB before transmitting authorizations, surfaces background storage failures through `onPersistenceError`, and uses an identity-scoped Web Lock to prevent concurrent signing from multiple tabs; the public constructor and explicit `ephemeral()` mode retain injectable/in-memory operation for compatibility, tests, and free interoperability experiments. The `BuyerChannelStore` contract now supports an atomic authorization commit, an optional async `flush()` durability barrier, and lifecycle cleanup; the buyer stack persists and recovers ReserveAuth/SpendingAuth signatures, reserve/top-up state, encoded metadata, and per-service usage. Browser uploads chunk above 192 KiB to stay under the ~256 KiB SCTP message ceiling (`ProxyMux` gained an `uploadThresholdBytes` option). The client takes any address-bearing ethers `AbstractSigner` (`BuyerSigner`) instead of requiring a concrete `Wallet`, supports full `RTCIceServer` entries (TURN credentials) plus `iceTransportPolicy`, reports the selected WebRTC path (`direct`/`relay`/`unknown`) via `onConnectionInfo` without exposing addresses, and ships a working browser example page (`packages/web-sdk/examples/example.html`, served by `pnpm --filter @antseed/web-sdk run example`).
- Added `@antseed/relay`, the web relay browser buyers need: a DHT-discovered seller snapshot at `GET /sellers` and a `WS /bridge/` byte pipe to the seller's TCP signaling port. The bridge dials only cached seller endpoints, refuses private/internal address ranges for DHT-announced sellers (checked for IP literals and again at DNS resolution), enforces per-IP, per-seller, and global bridge caps plus a configurable WebSocket message-size limit, supports an optional browser Origin allowlist, exposes cache-aware readiness and privacy-safe aggregate metrics, and supports `RELAY_TRUST_PROXY=1` for deployments behind a TLS-terminating proxy.
- CLI: `antseed buyer deposit` is now the deposit flow — it prints the node's funding address and a terminal QR code (EIP-681 payment request), serves the browser-wallet checkout page and prints its link for users who prefer depositing from a connected wallet, then watches and deposits incoming USDC into the buyer's credits automatically via the gasless relayer sweep. Works through a running buyer daemon or standalone with an ephemeral node; `--amount` prefills the QR request and checkout, `--no-watch` prints without waiting, and `antseed deposit` works as an alias. The previous `buyer deposit ` direct on-chain deposit (hot wallet pays gas) moved to `buyer deposit --onchain `.
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index ee0c69178..6b917fb9d 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -71,6 +71,33 @@ Build desktop assets:
npm run build
```
+### Telegram feedback channel
+
+The Help & Support feedback action posts through a dedicated Telegram bot. Source builds
+keep the action disabled unless both variables are configured:
+
+```bash
+export ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN='123456:bot-token'
+export ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID='@channelusername' # or -100…
+npm run dev
+```
+
+Release CI runs `scripts/bake-feedback-telegram-config.mjs --require` before
+compilation, using GitHub Actions secrets with the same names. The generated
+source defaults remain `null` in git; the runtime environment overrides baked
+values for local testing.
+
+Use a disposable bot that is an administrator only in the feedback channel and
+grant only **Post Messages**. Never reuse the personal Telegram bridge bot. The
+token is compiled into public Electron artifacts and can be extracted, so treat
+it as exposed: monitor the channel, remove the bot or revoke the token to disable
+submissions, and rotate it immediately if abused. A server-side relay is required
+before this channel handles sensitive or high-volume production feedback.
+
+Diagnostic logs are opt-in and privacy-redacted before upload. The app masks
+credentials, emails, local home paths, IP addresses, wallet addresses, and peer
+IDs, retains at most 500 recent entries, and caps the log at 512 KiB.
+
Start app from built assets:
```bash
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index af2d783c8..80dd19fcd 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -9,7 +9,7 @@
"type": "module",
"main": "dist/main/main.js",
"scripts": {
- "ensure:cli-dist": "pnpm -C ../../packages/api-adapter build && pnpm -C ../../packages/protocol build && pnpm -C ../../packages/buyer-core build && pnpm -C ../../packages/router-core build && pnpm -C ../../plugins/router-local build && pnpm -C ../../packages/node build && pnpm -C ../payments build:server && pnpm -C ../cli build",
+ "ensure:cli-dist": "pnpm -C ../../packages/api-adapter build && pnpm -C ../../packages/protocol build && pnpm -C ../../packages/buyer-core build && pnpm -C ../../packages/node build && pnpm -C ../../packages/provider-core build && pnpm -C ../../packages/ant-agent build && pnpm -C ../../packages/router-core build && pnpm -C ../../plugins/router-local build && pnpm -C ../payments build:server && pnpm -C ../cli build",
"brand:electron-dev": "node ./scripts/brand-electron-dev.mjs",
"models:update-metadata": "pnpm -C ../../packages/node build && node ./scripts/update-model-metadata.mjs",
"ensure:native": "npm run ensure:runtime-native",
diff --git a/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs b/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs
new file mode 100644
index 000000000..ada74bdaf
--- /dev/null
+++ b/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict';
+import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+
+const repoRoot = resolve(import.meta.dirname, '..', '..', '..');
+const script = join(repoRoot, 'scripts', 'bake-feedback-telegram-config.mjs');
+
+test('bakes Telegram feedback credentials without printing the token', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'antseed-feedback-bake-'));
+ const target = join(tempDir, 'baked-defaults.ts');
+ const token = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':');
+ writeFileSync(target, [
+ 'export const BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN: string | null = null;',
+ 'export const BAKED_FEEDBACK_TELEGRAM_CHAT_ID: string | null = null;',
+ '',
+ ].join('\n'));
+
+ const result = spawnSync(process.execPath, [script, '--require', '--target', target], {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: token,
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback',
+ },
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.doesNotMatch(`${result.stdout}${result.stderr}`, new RegExp(token));
+ const baked = readFileSync(target, 'utf8');
+ assert.match(baked, new RegExp(token));
+ assert.match(baked, /@antseed_feedback/);
+});
+
+test('required mode rejects incomplete configuration', () => {
+ const result = spawnSync(process.execPath, [script, '--require'], {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'),
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '',
+ },
+ });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /must both be set/);
+});
diff --git a/apps/desktop/src/main/feedback/config.test.ts b/apps/desktop/src/main/feedback/config.test.ts
new file mode 100644
index 000000000..a59f7a345
--- /dev/null
+++ b/apps/desktop/src/main/feedback/config.test.ts
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import {
+ parseTelegramChatId,
+ resolveFeedbackTelegramConfig,
+} from './config.js';
+
+const validToken = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':');
+
+test('reports missing source-build configuration', () => {
+ assert.deepEqual(resolveFeedbackTelegramConfig({}, { botToken: null, chatId: null }), {
+ configured: false,
+ error: 'Feedback is unavailable in this build.',
+ });
+});
+
+test('resolves a valid runtime Telegram feedback configuration', () => {
+ const result = resolveFeedbackTelegramConfig({
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken,
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback',
+ });
+ assert.equal(result.configured, true);
+ if (result.configured) {
+ assert.equal(result.config.botToken, validToken);
+ assert.equal(result.config.chatId, '@antseed_feedback');
+ }
+});
+
+test('uses baked configuration unless runtime values override it', () => {
+ const baked = { botToken: validToken, chatId: '@baked_feedback' };
+ const bakedResult = resolveFeedbackTelegramConfig({}, baked);
+ assert.equal(bakedResult.configured, true);
+ if (bakedResult.configured) assert.equal(bakedResult.config.chatId, '@baked_feedback');
+
+ const runtimeResult = resolveFeedbackTelegramConfig({
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken,
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@runtime_feedback',
+ }, baked);
+ assert.equal(runtimeResult.configured, true);
+ if (runtimeResult.configured) assert.equal(runtimeResult.config.chatId, '@runtime_feedback');
+});
+
+test('runtime override requires both values and can disable baked defaults', () => {
+ const baked = { botToken: validToken, chatId: '@baked_feedback' };
+ const incomplete = resolveFeedbackTelegramConfig({
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken,
+ }, baked);
+ assert.equal(incomplete.configured, false);
+
+ const disabled = resolveFeedbackTelegramConfig({
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: '',
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '',
+ }, baked);
+ assert.deepEqual(disabled, { configured: false, error: 'Feedback is unavailable in this build.' });
+});
+
+test('rejects invalid tokens and parses numeric and public-channel chat identifiers', () => {
+ const invalidToken = resolveFeedbackTelegramConfig({
+ ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: 'not-a-token',
+ ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback',
+ });
+ assert.deepEqual(invalidToken, {
+ configured: false,
+ error: 'Telegram feedback bot configuration is invalid.',
+ });
+ assert.equal(parseTelegramChatId('-1001234567890'), -1001234567890);
+ assert.equal(parseTelegramChatId('@antseed_feedback'), '@antseed_feedback');
+ assert.equal(parseTelegramChatId('not a chat'), null);
+});
diff --git a/apps/desktop/src/main/feedback/config.ts b/apps/desktop/src/main/feedback/config.ts
new file mode 100644
index 000000000..77dac5cce
--- /dev/null
+++ b/apps/desktop/src/main/feedback/config.ts
@@ -0,0 +1,74 @@
+import {
+ BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN,
+ BAKED_FEEDBACK_TELEGRAM_CHAT_ID,
+} from '../generated/baked-defaults.js';
+
+export const FEEDBACK_TELEGRAM_BOT_TOKEN_ENV = 'ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN';
+export const FEEDBACK_TELEGRAM_CHAT_ID_ENV = 'ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID';
+
+export type FeedbackTelegramConfig = {
+ botToken: string;
+ chatId: number | string;
+};
+
+export type FeedbackTelegramConfigResolution =
+ | { configured: true; config: FeedbackTelegramConfig }
+ | { configured: false; error: string };
+
+type FeedbackTelegramBakedDefaults = {
+ botToken: string | null;
+ chatId: string | null;
+};
+
+export function isValidTelegramBotToken(value: string): boolean {
+ return /^\d+:[A-Za-z0-9_-]{20,}$/.test(value);
+}
+
+export function parseTelegramChatId(value: string): number | string | null {
+ const trimmed = value.trim();
+ if (/^-?\d+$/.test(trimmed)) {
+ const parsed = Number(trimmed);
+ return Number.isSafeInteger(parsed) ? parsed : null;
+ }
+ if (/^@[A-Za-z][A-Za-z0-9_]{4,31}$/.test(trimmed)) {
+ return trimmed;
+ }
+ return null;
+}
+
+export function resolveFeedbackTelegramConfig(
+ env: NodeJS.ProcessEnv = process.env,
+ baked: FeedbackTelegramBakedDefaults = {
+ botToken: BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN,
+ chatId: BAKED_FEEDBACK_TELEGRAM_CHAT_ID,
+ },
+): FeedbackTelegramConfigResolution {
+ const hasRuntimeOverride =
+ Object.prototype.hasOwnProperty.call(env, FEEDBACK_TELEGRAM_BOT_TOKEN_ENV)
+ || Object.prototype.hasOwnProperty.call(env, FEEDBACK_TELEGRAM_CHAT_ID_ENV);
+ const botToken = (
+ hasRuntimeOverride
+ ? env[FEEDBACK_TELEGRAM_BOT_TOKEN_ENV]
+ : baked.botToken
+ )?.trim() ?? '';
+ const rawChatId = (
+ hasRuntimeOverride
+ ? env[FEEDBACK_TELEGRAM_CHAT_ID_ENV]
+ : baked.chatId
+ )?.trim() ?? '';
+
+ if (!botToken && !rawChatId) {
+ return { configured: false, error: 'Feedback is unavailable in this build.' };
+ }
+ if (!botToken || !rawChatId) {
+ return { configured: false, error: 'Telegram feedback configuration is incomplete.' };
+ }
+ if (!isValidTelegramBotToken(botToken)) {
+ return { configured: false, error: 'Telegram feedback bot configuration is invalid.' };
+ }
+ const chatId = parseTelegramChatId(rawChatId);
+ if (chatId === null) {
+ return { configured: false, error: 'Telegram feedback channel configuration is invalid.' };
+ }
+ return { configured: true, config: { botToken, chatId } };
+}
diff --git a/apps/desktop/src/main/feedback/diagnostic-log.test.ts b/apps/desktop/src/main/feedback/diagnostic-log.test.ts
new file mode 100644
index 000000000..b36783d98
--- /dev/null
+++ b/apps/desktop/src/main/feedback/diagnostic-log.test.ts
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import type { LogEvent } from '../runtime/log-parser.js';
+import { buildFeedbackDiagnosticLog } from './diagnostic-log.js';
+
+function log(line: string, timestamp = 1_700_000_000_000): LogEvent {
+ return { mode: 'connect', stream: 'system', line, timestamp };
+}
+
+test('privacy-redacts sensitive diagnostic values with stable placeholders', () => {
+ const peerId = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd';
+ const botToken = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':');
+ const webhook = ['https://discord.com/api/webhooks', '123456789', 'webhook-secret-value'].join('/');
+ const output = new TextDecoder().decode(buildFeedbackDiagnosticLog([
+ log(`token=${botToken} authorization=Basic-sensitive webhook=${webhook} email=user@example.com`),
+ log(`home=/Users/alex/project ip=192.168.1.10 wallet=0x${'a'.repeat(40)} peer=${peerId}`),
+ log('again user@example.com and 192.168.1.10'),
+ ]));
+
+ assert.doesNotMatch(output, /abcdefghijklmnopqrstuvwxyz_ABCDEF/);
+ assert.doesNotMatch(output, /user@example\.com/);
+ assert.doesNotMatch(output, /webhook-secret-value|Basic-sensitive/);
+ assert.doesNotMatch(output, /\/Users\/alex/);
+ assert.doesNotMatch(output, /192\.168\.1\.10/);
+ assert.doesNotMatch(output, new RegExp(peerId));
+ assert.equal(output.match(//g)?.length, 2);
+ assert.equal(output.match(//g)?.length, 2);
+ assert.match(output, //);
+ assert.match(output, //);
+ assert.match(output, //);
+ assert.match(output, //);
+});
+
+test('retains only the newest 500 runtime entries', () => {
+ const logs = Array.from({ length: 501 }, (_value, index) => log(`entry-${index}`, index));
+ const output = new TextDecoder().decode(buildFeedbackDiagnosticLog(logs));
+ assert.doesNotMatch(output, /entry-0(?:\D|$)/);
+ assert.match(output, /entry-1(?:\D|$)/);
+ assert.match(output, /entry-500(?:\D|$)/);
+});
+
+test('keeps the newest entries within the byte budget', () => {
+ const output = new TextDecoder().decode(buildFeedbackDiagnosticLog([
+ log(`old-${'x'.repeat(100)}`, 1),
+ log(`middle-${'y'.repeat(100)}`, 2),
+ log('newest-entry', 3),
+ ], 260));
+ assert.ok(Buffer.byteLength(output, 'utf8') <= 260);
+ assert.match(output, /newest-entry/);
+ assert.doesNotMatch(output, /old-/);
+ assert.match(output, /earlier log entries omitted/);
+});
diff --git a/apps/desktop/src/main/feedback/diagnostic-log.ts b/apps/desktop/src/main/feedback/diagnostic-log.ts
new file mode 100644
index 000000000..3a671aff7
--- /dev/null
+++ b/apps/desktop/src/main/feedback/diagnostic-log.ts
@@ -0,0 +1,93 @@
+import type { LogEvent } from '../runtime/log-parser.js';
+
+export const FEEDBACK_DIAGNOSTIC_MAX_ENTRIES = 500;
+export const FEEDBACK_DIAGNOSTIC_MAX_BYTES = 512 * 1024;
+
+type RedactionCategory =
+ | 'TOKEN'
+ | 'SECRET'
+ | 'WEBHOOK'
+ | 'EMAIL'
+ | 'HOME'
+ | 'IP'
+ | 'WALLET'
+ | 'PEER';
+
+class StableRedactor {
+ private readonly values = new Map();
+ private readonly counts = new Map();
+
+ private placeholder(category: RedactionCategory, value: string): string {
+ const key = `${category}:${value.toLowerCase()}`;
+ const existing = this.values.get(key);
+ if (existing) return existing;
+ const next = (this.counts.get(category) ?? 0) + 1;
+ this.counts.set(category, next);
+ const placeholder = `<${category}_${next}>`;
+ this.values.set(key, placeholder);
+ return placeholder;
+ }
+
+ redact(input: string): string {
+ let output = input;
+ output = output.replace(
+ /https?:\/\/(?:discord(?:app)?\.com\/api\/webhooks|hooks\.slack\.com\/services)\/\S+/gi,
+ (value) => this.placeholder('WEBHOOK', value),
+ );
+ output = output.replace(/\b\d{6,}:[A-Za-z0-9_-]{20,}\b/g, (value) => this.placeholder('TOKEN', value));
+ output = output.replace(/\bBearer\s+[^\s,;]+/gi, (value) => `Bearer ${this.placeholder('TOKEN', value)}`);
+ output = output.replace(
+ /\b(api[_-]?key|token|secret|password|private[_-]?key|authorization)(\s*[:=]\s*)([^\s,;]+)/gi,
+ (_match, key: string, separator: string, value: string) => `${key}${separator}${this.placeholder('SECRET', value)}`,
+ );
+ output = output.replace(/\b0x[0-9a-f]{64}\b/gi, (value) => this.placeholder('SECRET', value));
+ output = output.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, (value) => this.placeholder('EMAIL', value));
+ output = output.replace(/(?:\/Users\/[^/\s]+|\/home\/[^/\s]+|[A-Z]:\\Users\\[^\\\s]+)/gi, (value) => this.placeholder('HOME', value));
+ output = output.replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b/g, (value) => this.placeholder('IP', value));
+ output = output.replace(/\b(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}(?::\d{1,5})?\b/gi, (value) => this.placeholder('IP', value));
+ output = output.replace(/\b0x[0-9a-f]{40}\b/gi, (value) => this.placeholder('WALLET', value));
+ output = output.replace(/\b[0-9a-f]{40}\b/gi, (value) => this.placeholder('PEER', value));
+ return output;
+ }
+}
+
+function formatEntry(entry: LogEvent, redactor: StableRedactor): string {
+ const timestamp = new Date(entry.timestamp).toISOString();
+ return `${timestamp} [${entry.mode}] [${entry.stream}] ${redactor.redact(entry.line)}`;
+}
+
+export function buildFeedbackDiagnosticLog(
+ logs: readonly LogEvent[],
+ maxBytes = FEEDBACK_DIAGNOSTIC_MAX_BYTES,
+): Uint8Array {
+ if (maxBytes <= 0) return new Uint8Array();
+ const redactor = new StableRedactor();
+ const formatted = logs
+ .slice(-FEEDBACK_DIAGNOSTIC_MAX_ENTRIES)
+ .map((entry) => formatEntry(entry, redactor));
+ const header = [
+ 'AntSeed diagnostic log',
+ 'Privacy redaction applied: secrets, identity, network, and local path values are masked.',
+ '',
+ ];
+ const selected: string[] = [];
+ let currentBytes = Buffer.byteLength(header.join('\n'), 'utf8');
+
+ for (let index = formatted.length - 1; index >= 0; index -= 1) {
+ const line = formatted[index]!;
+ const lineBytes = Buffer.byteLength(`${line}\n`, 'utf8');
+ if (currentBytes + lineBytes > maxBytes) break;
+ selected.unshift(line);
+ currentBytes += lineBytes;
+ }
+
+ const omitted = formatted.length - selected.length;
+ const omissionLine = omitted > 0 ? `[${omitted} earlier log entries omitted]\n` : '';
+ let text = `${header.join('\n')}${omissionLine}${selected.join('\n')}${selected.length > 0 ? '\n' : ''}`;
+ let bytes = Buffer.from(text, 'utf8');
+ if (bytes.byteLength > maxBytes) {
+ text = 'AntSeed diagnostic log\n[Log omitted because the remaining attachment budget was too small.]\n';
+ bytes = Buffer.from(text, 'utf8').subarray(0, maxBytes);
+ }
+ return new Uint8Array(bytes);
+}
diff --git a/apps/desktop/src/main/feedback/service.test.ts b/apps/desktop/src/main/feedback/service.test.ts
new file mode 100644
index 000000000..2f39a76a3
--- /dev/null
+++ b/apps/desktop/src/main/feedback/service.test.ts
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import type { FeedbackSubmitRequest } from '../../shared/feedback.js';
+import { formatFeedbackMessage, submitTelegramFeedback, validateFeedbackRequest } from './service.js';
+
+const originalFetch = globalThis.fetch;
+const config = {
+ botToken: ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'),
+ chatId: '@antseed_feedback',
+};
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+function request(overrides: Partial = {}): FeedbackSubmitRequest {
+ return {
+ feedback: 'The app is useful.',
+ contactEmail: 'user@example.com',
+ images: [],
+ includeDiagnosticLogs: false,
+ ...overrides,
+ };
+}
+
+test('validates feedback and attachment limits', () => {
+ assert.throws(() => validateFeedbackRequest(request({ feedback: ' ' })), /enter some feedback/i);
+ assert.throws(() => validateFeedbackRequest(request({ feedback: 'x'.repeat(3_001) })), /3,000 characters/i);
+ assert.throws(() => validateFeedbackRequest(request({ contactEmail: 'invalid' })), /valid contact email/i);
+ assert.throws(() => validateFeedbackRequest(request({
+ images: Array.from({ length: 11 }, (_value, index) => ({
+ name: `${index}.png`,
+ mimeType: 'image/png',
+ size: 1,
+ dataBase64: 'YQ==',
+ })),
+ })), /no more than 10 images/i);
+ assert.doesNotThrow(() => validateFeedbackRequest(request()));
+});
+
+test('formats one plain root message with app metadata', () => {
+ const content = formatFeedbackMessage({
+ feedbackId: 'ABC12345',
+ feedback: 'Great routing.',
+ contactEmail: '',
+ appVersion: '0.2.27',
+ platform: 'darwin arm64',
+ imageCount: 2,
+ diagnosticStatus: 'included',
+ });
+ assert.match(content, /^AntSeed Feedback · ABC12345/);
+ assert.match(content, /AntSeed VPR: 0\.2\.27/);
+ assert.match(content, /Diagnostic logs: included \(privacy-redacted\)/);
+
+ const maximumContent = formatFeedbackMessage({
+ feedbackId: 'ABC12345',
+ feedback: 'x'.repeat(3_000),
+ contactEmail: `${'a'.repeat(242)}@example.com`,
+ appVersion: '0.2.27',
+ platform: 'darwin arm64 25.0.0',
+ imageCount: 10,
+ diagnosticStatus: 'included',
+ });
+ assert.ok(maximumContent.length <= 4_096);
+});
+
+test('returns partial success when Telegram accepts text but rejects an image', async () => {
+ const methods: string[] = [];
+ globalThis.fetch = async (url) => {
+ const method = String(url).split('/').pop()!;
+ methods.push(method);
+ if (method === 'sendPhoto') {
+ return new Response(JSON.stringify({ ok: false, error_code: 400, description: 'Bad photo' }), { status: 400 });
+ }
+ return new Response(JSON.stringify({
+ ok: true,
+ result: { message_id: 77, chat: { id: -1001, type: 'channel' }, date: 1 },
+ }));
+ };
+
+ const result = await submitTelegramFeedback({
+ config,
+ request: request({
+ images: [{
+ name: 'image.png',
+ mimeType: 'image/png',
+ size: 3,
+ dataBase64: Buffer.from('abc').toString('base64'),
+ }],
+ includeDiagnosticLogs: true,
+ }),
+ logs: [{ mode: 'connect', stream: 'system', line: 'ready', timestamp: 1 }],
+ appVersion: '0.2.27',
+ });
+
+ assert.equal(result.ok, true);
+ assert.match(result.attachmentWarnings?.join(' ') ?? '', /image attachments/);
+ assert.deepEqual(methods, ['sendMessage', 'sendPhoto', 'sendDocument']);
+});
+
+test('retains failure semantics when the root message is rejected', async () => {
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ ok: false,
+ error_code: 401,
+ description: 'Unauthorized',
+ }), { status: 401 });
+ const result = await submitTelegramFeedback({
+ config,
+ request: request(),
+ logs: [],
+ appVersion: '0.2.27',
+ });
+ assert.deepEqual(result, { ok: false, error: 'Unable to send feedback. Please try again.' });
+});
diff --git a/apps/desktop/src/main/feedback/service.ts b/apps/desktop/src/main/feedback/service.ts
new file mode 100644
index 000000000..237f84141
--- /dev/null
+++ b/apps/desktop/src/main/feedback/service.ts
@@ -0,0 +1,196 @@
+import { randomUUID } from 'node:crypto';
+import { release } from 'node:os';
+import {
+ FEEDBACK_IMAGE_MIME_TYPES,
+ FEEDBACK_MAX_ATTACHMENT_BYTES,
+ FEEDBACK_MAX_EMAIL_LENGTH,
+ FEEDBACK_MAX_IMAGES,
+ FEEDBACK_MAX_TEXT_LENGTH,
+ type FeedbackImageInput,
+ type FeedbackSubmitRequest,
+ type FeedbackSubmitResult,
+} from '../../shared/feedback.js';
+import type { LogEvent } from '../runtime/log-parser.js';
+import { createTelegramBotClient, type TgUpload } from '../telegram/bot-api.js';
+import type { FeedbackTelegramConfig } from './config.js';
+import {
+ buildFeedbackDiagnosticLog,
+ FEEDBACK_DIAGNOSTIC_MAX_BYTES,
+} from './diagnostic-log.js';
+
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const MIN_DIAGNOSTIC_BYTES = 256;
+
+class FeedbackValidationError extends Error {}
+
+type PreparedFeedbackRequest = {
+ feedback: string;
+ contactEmail: string;
+ images: TgUpload[];
+ imageBytes: number;
+ includeDiagnosticLogs: boolean;
+};
+
+function safeFilename(name: string, fallback: string): string {
+ const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim() || fallback;
+ return leaf.replace(/[^A-Za-z0-9._ -]/g, '_').slice(0, 120) || fallback;
+}
+
+function decodeImage(image: FeedbackImageInput, index: number): TgUpload {
+ if (!FEEDBACK_IMAGE_MIME_TYPES.includes(image.mimeType)) {
+ throw new FeedbackValidationError(`Image ${index + 1} has an unsupported format.`);
+ }
+ if (!Number.isSafeInteger(image.size) || image.size <= 0) {
+ throw new FeedbackValidationError(`Image ${index + 1} has an invalid size.`);
+ }
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(image.dataBase64) || image.dataBase64.length % 4 !== 0) {
+ throw new FeedbackValidationError(`Image ${index + 1} contains invalid data.`);
+ }
+ const bytes = Buffer.from(image.dataBase64, 'base64');
+ if (bytes.byteLength !== image.size) {
+ throw new FeedbackValidationError(`Image ${index + 1} size does not match its data.`);
+ }
+ return {
+ data: new Uint8Array(bytes),
+ filename: safeFilename(image.name, `feedback-image-${index + 1}`),
+ mimeType: image.mimeType,
+ };
+}
+export function validateFeedbackRequest(request: unknown): PreparedFeedbackRequest {
+ if (!request || typeof request !== 'object' || Array.isArray(request)) {
+ throw new FeedbackValidationError('Invalid feedback request.');
+ }
+ const raw = request as Partial;
+ const feedback = typeof raw.feedback === 'string' ? raw.feedback.trim() : '';
+ const contactEmail = typeof raw.contactEmail === 'string' ? raw.contactEmail.trim() : '';
+ if (!feedback) throw new FeedbackValidationError('Please enter some feedback before sending.');
+ if (feedback.length > FEEDBACK_MAX_TEXT_LENGTH) {
+ throw new FeedbackValidationError(`Feedback must be ${FEEDBACK_MAX_TEXT_LENGTH.toLocaleString()} characters or fewer.`);
+ }
+ if (contactEmail.length > FEEDBACK_MAX_EMAIL_LENGTH || (contactEmail && !EMAIL_PATTERN.test(contactEmail))) {
+ throw new FeedbackValidationError('Enter a valid contact email or leave it blank.');
+ }
+ if (!Array.isArray(raw.images)) throw new FeedbackValidationError('Invalid image attachments.');
+ if (raw.images.length > FEEDBACK_MAX_IMAGES) {
+ throw new FeedbackValidationError(`Attach no more than ${FEEDBACK_MAX_IMAGES} images.`);
+ }
+ const images = raw.images.map((image, index) => decodeImage(image, index));
+ const imageBytes = images.reduce((total, image) => total + image.data.byteLength, 0);
+ if (imageBytes > FEEDBACK_MAX_ATTACHMENT_BYTES) {
+ throw new FeedbackValidationError('Attachments exceed the 8 MiB total limit.');
+ }
+ return {
+ feedback,
+ contactEmail,
+ images,
+ imageBytes,
+ includeDiagnosticLogs: raw.includeDiagnosticLogs === true,
+ };
+}
+
+export function formatFeedbackMessage(input: {
+ feedbackId: string;
+ feedback: string;
+ contactEmail: string;
+ appVersion: string;
+ platform: string;
+ imageCount: number;
+ diagnosticStatus: 'included' | 'omitted' | 'not requested';
+}): string {
+ const metadata = [
+ input.contactEmail ? `Contact: ${input.contactEmail}` : null,
+ `AntSeed VPR: ${input.appVersion}`,
+ `Platform: ${input.platform}`,
+ `Images: ${input.imageCount}`,
+ `Diagnostic logs: ${input.diagnosticStatus}${input.diagnosticStatus === 'included' ? ' (privacy-redacted)' : ''}`,
+ ].filter((line): line is string => Boolean(line));
+ return [`AntSeed Feedback · ${input.feedbackId}`, '', input.feedback, '', ...metadata].join('\n');
+}
+
+export async function submitTelegramFeedback(input: {
+ config: FeedbackTelegramConfig;
+ request: unknown;
+ logs: readonly LogEvent[];
+ appVersion: string;
+}): Promise {
+ let prepared: PreparedFeedbackRequest;
+ try {
+ prepared = validateFeedbackRequest(input.request);
+ } catch (error) {
+ return {
+ ok: false,
+ error: error instanceof FeedbackValidationError ? error.message : 'Invalid feedback request.',
+ };
+ }
+
+ const feedbackId = randomUUID().slice(0, 8).toUpperCase();
+ const warnings: string[] = [];
+ let diagnosticLog: TgUpload | null = null;
+ if (prepared.includeDiagnosticLogs) {
+ const remainingBytes = FEEDBACK_MAX_ATTACHMENT_BYTES - prepared.imageBytes;
+ if (remainingBytes < MIN_DIAGNOSTIC_BYTES) {
+ warnings.push('Diagnostic logs were omitted because the images use the attachment limit.');
+ } else {
+ const data = buildFeedbackDiagnosticLog(
+ input.logs,
+ Math.min(remainingBytes, FEEDBACK_DIAGNOSTIC_MAX_BYTES),
+ );
+ diagnosticLog = {
+ data,
+ filename: `antseed-diagnostics-${feedbackId.toLowerCase()}.txt`,
+ mimeType: 'text/plain',
+ };
+ }
+ }
+
+ const content = formatFeedbackMessage({
+ feedbackId,
+ feedback: prepared.feedback,
+ contactEmail: prepared.contactEmail,
+ appVersion: input.appVersion,
+ platform: `${process.platform} ${process.arch} ${release()}`,
+ imageCount: prepared.images.length,
+ diagnosticStatus: diagnosticLog ? 'included' : prepared.includeDiagnosticLogs ? 'omitted' : 'not requested',
+ });
+ const client = createTelegramBotClient(input.config.botToken);
+ let rootMessageId: number;
+ try {
+ const root = await client.sendMessage(input.config.chatId, content);
+ rootMessageId = root.message_id;
+ } catch (error) {
+ console.error('[feedback] Failed to send Telegram feedback:', error instanceof Error ? error.message : String(error));
+ return { ok: false, error: 'Unable to send feedback. Please try again.' };
+ }
+
+ if (prepared.images.length > 0) {
+ try {
+ const options = { caption: `Feedback ${feedbackId}`, replyToMessageId: rootMessageId };
+ if (prepared.images.length === 1) {
+ await client.sendPhoto(input.config.chatId, prepared.images[0]!, options);
+ } else {
+ await client.sendMediaGroup(input.config.chatId, prepared.images, options);
+ }
+ } catch (error) {
+ console.error('[feedback] Telegram accepted feedback but rejected image attachments:', error instanceof Error ? error.message : String(error));
+ warnings.push('Feedback was sent, but the image attachments could not be uploaded.');
+ }
+ }
+
+ if (diagnosticLog) {
+ try {
+ await client.sendDocument(input.config.chatId, diagnosticLog, {
+ caption: `Feedback ${feedbackId} · privacy-redacted diagnostics`,
+ replyToMessageId: rootMessageId,
+ });
+ } catch (error) {
+ console.error('[feedback] Telegram accepted feedback but rejected diagnostic logs:', error instanceof Error ? error.message : String(error));
+ warnings.push('Feedback was sent, but the diagnostic log could not be uploaded.');
+ }
+ }
+
+ return {
+ ok: true,
+ feedbackId,
+ ...(warnings.length > 0 ? { attachmentWarnings: warnings } : {}),
+ };
+}
diff --git a/apps/desktop/src/main/generated/baked-defaults.ts b/apps/desktop/src/main/generated/baked-defaults.ts
index efbd731bf..5ad68a224 100644
--- a/apps/desktop/src/main/generated/baked-defaults.ts
+++ b/apps/desktop/src/main/generated/baked-defaults.ts
@@ -13,3 +13,9 @@
* disables the baseline entirely).
*/
export const BAKED_COMPARABLE_PRICES_URL: string | null = null;
+
+/** Dedicated Telegram bot used only for the public desktop feedback channel. */
+export const BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN: string | null = null;
+
+/** Numeric channel ID or public @channelusername for desktop feedback. */
+export const BAKED_FEEDBACK_TELEGRAM_CHAT_ID: string | null = null;
diff --git a/apps/desktop/src/main/ipc/feedback.ts b/apps/desktop/src/main/ipc/feedback.ts
new file mode 100644
index 000000000..62cee02f6
--- /dev/null
+++ b/apps/desktop/src/main/ipc/feedback.ts
@@ -0,0 +1,26 @@
+import { app, ipcMain } from 'electron';
+import type { LogEvent } from '../runtime/log-parser.js';
+import { resolveFeedbackTelegramConfig } from '../feedback/config.js';
+import { submitTelegramFeedback } from '../feedback/service.js';
+
+export function registerFeedbackIpc(deps: { logBuffer: readonly LogEvent[] }): void {
+ ipcMain.handle('feedback:get-status', () => {
+ const resolution = resolveFeedbackTelegramConfig();
+ return resolution.configured
+ ? { configured: true }
+ : { configured: false, error: resolution.error };
+ });
+
+ ipcMain.handle('feedback:submit', async (_event, request: unknown) => {
+ const resolution = resolveFeedbackTelegramConfig();
+ if (!resolution.configured) {
+ return { ok: false, error: resolution.error };
+ }
+ return submitTelegramFeedback({
+ config: resolution.config,
+ request,
+ logs: deps.logBuffer,
+ appVersion: app.getVersion(),
+ });
+ });
+}
diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts
index ee64edc0b..148b336ab 100644
--- a/apps/desktop/src/main/main.ts
+++ b/apps/desktop/src/main/main.ts
@@ -68,6 +68,7 @@ import { LOCALHOST_URL } from './constants.js';
import { registerAppIpc } from './ipc/app.js';
import { registerDesktopIpc } from './ipc/desktop.js';
import { registerFloatIpc } from './ipc/float.js';
+import { registerFeedbackIpc } from './ipc/feedback.js';
import { registerPaymentsIpc } from './ipc/payments.js';
import { registerRuntimeIpc } from './ipc/runtime.js';
import { registerSystemProxyIpc } from './ipc/system-proxy.js';
@@ -265,6 +266,7 @@ function getCombinedProcessState(): RuntimeProcessState[] {
registerPaymentsIpc();
registerDesktopIpc();
registerAppIpc();
+registerFeedbackIpc({ logBuffer });
registerFloatIpc();
registerSystemProxyIpc({ processManager });
registerRuntimeIpc({
diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts
index 0010c5b93..41270cd61 100644
--- a/apps/desktop/src/main/preload.cts
+++ b/apps/desktop/src/main/preload.cts
@@ -1,5 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { RuntimeMode, RuntimeProcessState, StartOptions } from './runtime/process-manager.js';
+import type {
+ FeedbackStatus,
+ FeedbackSubmitRequest,
+ FeedbackSubmitResult,
+} from '../shared/feedback.js';
type LogEvent = {
mode: RuntimeMode;
@@ -184,6 +189,12 @@ const api = {
getAppVersion(): Promise {
return ipcRenderer.invoke('app:get-version') as Promise;
},
+ feedbackGetStatus(): Promise {
+ return ipcRenderer.invoke('feedback:get-status') as Promise;
+ },
+ feedbackSubmit(request: FeedbackSubmitRequest): Promise {
+ return ipcRenderer.invoke('feedback:submit', request) as Promise;
+ },
getOpenRouterReferencePrices(): Promise> {
return ipcRenderer.invoke('openrouter:reference-prices') as Promise<
Record
diff --git a/apps/desktop/src/main/telegram/bot-api.test.ts b/apps/desktop/src/main/telegram/bot-api.test.ts
new file mode 100644
index 000000000..533758f58
--- /dev/null
+++ b/apps/desktop/src/main/telegram/bot-api.test.ts
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import { createTelegramBotClient } from './bot-api.js';
+
+const originalFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+function telegramMessage(messageId: number) {
+ return { message_id: messageId, chat: { id: -1001, type: 'channel' }, date: 1 };
+}
+
+test('sends a media group and diagnostic document as multipart replies', async () => {
+ const calls: Array<{ url: string; body: FormData }> = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url: String(url), body: init?.body as FormData });
+ const result = String(url).endsWith('/sendMediaGroup')
+ ? [telegramMessage(2), telegramMessage(3)]
+ : telegramMessage(4);
+ return new Response(JSON.stringify({ ok: true, result }), {
+ headers: { 'content-type': 'application/json' },
+ });
+ };
+
+ const client = createTelegramBotClient(['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'));
+ const uploads = [
+ { data: new Uint8Array([1]), filename: 'one.png', mimeType: 'image/png' },
+ { data: new Uint8Array([2]), filename: 'two.png', mimeType: 'image/png' },
+ ];
+ await client.sendMediaGroup('@feedback', uploads, { caption: 'Feedback ABC', replyToMessageId: 1 });
+ await client.sendDocument('@feedback', {
+ data: new TextEncoder().encode('logs'),
+ filename: 'logs.txt',
+ mimeType: 'text/plain',
+ }, { replyToMessageId: 1 });
+
+ assert.equal(calls.length, 2);
+ assert.match(calls[0]!.url, /sendMediaGroup$/);
+ assert.equal(calls[0]!.body.get('chat_id'), '@feedback');
+ assert.equal(calls[0]!.body.get('reply_parameters'), JSON.stringify({ message_id: 1 }));
+ assert.match(String(calls[0]!.body.get('media')), /attach:\/\/photo0/);
+ assert.ok(calls[0]!.body.get('photo0') instanceof Blob);
+ assert.match(calls[1]!.url, /sendDocument$/);
+ assert.ok(calls[1]!.body.get('document') instanceof Blob);
+});
+
+test('sends one image as a multipart reply', async () => {
+ const calls: Array<{ url: string; body: FormData }> = [];
+ globalThis.fetch = async (url, init) => {
+ calls.push({ url: String(url), body: init?.body as FormData });
+ return new Response(JSON.stringify({ ok: true, result: telegramMessage(2) }), {
+ headers: { 'content-type': 'application/json' },
+ });
+ };
+
+ const client = createTelegramBotClient(['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'));
+ await client.sendPhoto('@feedback', {
+ data: new Uint8Array([1]),
+ filename: 'one.webp',
+ mimeType: 'image/webp',
+ }, { caption: 'Feedback ABC', replyToMessageId: 1 });
+
+ assert.equal(calls.length, 1);
+ assert.match(calls[0]!.url, /sendPhoto$/);
+ assert.equal(calls[0]!.body.get('reply_parameters'), JSON.stringify({ message_id: 1 }));
+ assert.ok(calls[0]!.body.get('photo') instanceof Blob);
+});
diff --git a/apps/desktop/src/main/telegram/bot-api.ts b/apps/desktop/src/main/telegram/bot-api.ts
index 45555e39a..88ec5f372 100644
--- a/apps/desktop/src/main/telegram/bot-api.ts
+++ b/apps/desktop/src/main/telegram/bot-api.ts
@@ -68,6 +68,40 @@ type TgResponse = {
parameters?: { retry_after?: number };
};
+export type TgChatId = number | string;
+
+export type TgUpload = {
+ data: Uint8Array;
+ filename: string;
+ mimeType: string;
+};
+
+function uploadBlob(upload: TgUpload): Blob {
+ const buffer = upload.data.buffer.slice(
+ upload.data.byteOffset,
+ upload.data.byteOffset + upload.data.byteLength,
+ ) as ArrayBuffer;
+ return new Blob([buffer], { type: upload.mimeType });
+}
+
+async function readTelegramResponse(method: string, response: Response): Promise {
+ let body: TgResponse;
+ try {
+ body = await response.json() as TgResponse;
+ } catch {
+ throw new TelegramApiError(method, response.status, 'Invalid response from Telegram');
+ }
+ if (!body.ok || body.result === undefined) {
+ throw new TelegramApiError(
+ method,
+ body.error_code ?? response.status,
+ body.description ?? 'Unknown error',
+ body.parameters?.retry_after ?? null,
+ );
+ }
+ return body.result;
+}
+
async function tgCall(
token: string,
method: string,
@@ -88,21 +122,28 @@ async function tgCall(
clearTimeout(timer);
}
- let body: TgResponse;
+ return readTelegramResponse(method, response);
+}
+
+async function tgMultipartCall(
+ token: string,
+ method: string,
+ formData: FormData,
+ timeoutMs = 30_000,
+): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ let response: Response;
try {
- body = await response.json() as TgResponse;
- } catch {
- throw new TelegramApiError(method, response.status, 'Invalid response from Telegram');
- }
- if (!body.ok || body.result === undefined) {
- throw new TelegramApiError(
- method,
- body.error_code ?? response.status,
- body.description ?? 'Unknown error',
- body.parameters?.retry_after ?? null,
- );
+ response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, {
+ method: 'POST',
+ body: formData,
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timer);
}
- return body.result;
+ return readTelegramResponse(method, response);
}
export type TelegramBotClient = {
@@ -112,10 +153,23 @@ export type TelegramBotClient = {
* timeoutS: 0 for an immediate snapshot of the queued backlog only.
*/
getUpdates(offset: number | undefined, signal?: AbortSignal, timeoutS?: number): Promise;
- sendMessage(chatId: number, text: string, options?: {
+ sendMessage(chatId: TgChatId, text: string, options?: {
replyMarkup?: TgReplyMarkup;
disableNotification?: boolean;
parseMode?: 'HTML';
+ replyToMessageId?: number;
+ }): Promise;
+ sendPhoto(chatId: TgChatId, photo: TgUpload, options?: {
+ caption?: string;
+ replyToMessageId?: number;
+ }): Promise;
+ sendMediaGroup(chatId: TgChatId, photos: TgUpload[], options?: {
+ caption?: string;
+ replyToMessageId?: number;
+ }): Promise;
+ sendDocument(chatId: TgChatId, document: TgUpload, options?: {
+ caption?: string;
+ replyToMessageId?: number;
}): Promise;
/**
* Streams partial text into an ephemeral draft bubble (Bot API 9.3+).
@@ -175,8 +229,48 @@ export function createTelegramBotClient(token: string): TelegramBotClient {
...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}),
...(options?.disableNotification ? { disable_notification: true } : {}),
...(options?.parseMode ? { parse_mode: options.parseMode } : {}),
+ ...(options?.replyToMessageId ? { reply_parameters: { message_id: options.replyToMessageId } } : {}),
}),
+ sendPhoto: (chatId, photo, options) => {
+ const formData = new FormData();
+ formData.append('chat_id', String(chatId));
+ formData.append('photo', uploadBlob(photo), photo.filename);
+ if (options?.caption) formData.append('caption', options.caption);
+ if (options?.replyToMessageId) {
+ formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId }));
+ }
+ return tgMultipartCall(token, 'sendPhoto', formData);
+ },
+
+ sendMediaGroup: (chatId, photos, options) => {
+ const formData = new FormData();
+ formData.append('chat_id', String(chatId));
+ formData.append('media', JSON.stringify(photos.map((_photo, index) => ({
+ type: 'photo',
+ media: `attach://photo${index}`,
+ ...(index === 0 && options?.caption ? { caption: options.caption } : {}),
+ }))));
+ photos.forEach((photo, index) => {
+ formData.append(`photo${index}`, uploadBlob(photo), photo.filename);
+ });
+ if (options?.replyToMessageId) {
+ formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId }));
+ }
+ return tgMultipartCall(token, 'sendMediaGroup', formData);
+ },
+
+ sendDocument: (chatId, document, options) => {
+ const formData = new FormData();
+ formData.append('chat_id', String(chatId));
+ formData.append('document', uploadBlob(document), document.filename);
+ if (options?.caption) formData.append('caption', options.caption);
+ if (options?.replyToMessageId) {
+ formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId }));
+ }
+ return tgMultipartCall(token, 'sendDocument', formData);
+ },
+
sendMessageDraft: async (chatId, draftId, text) => {
await tgCall(token, 'sendMessageDraft', {
chat_id: chatId,
diff --git a/apps/desktop/src/renderer/types/bridge.ts b/apps/desktop/src/renderer/types/bridge.ts
index b68e64816..cdd66ba4b 100644
--- a/apps/desktop/src/renderer/types/bridge.ts
+++ b/apps/desktop/src/renderer/types/bridge.ts
@@ -1,3 +1,9 @@
+import type {
+ FeedbackStatus,
+ FeedbackSubmitRequest,
+ FeedbackSubmitResult,
+} from '../../shared/feedback';
+
export type RuntimeMode = 'connect' | 'system-proxy';
export type RuntimeProcessState = {
@@ -299,6 +305,8 @@ export type DesktopBridge = {
getSystemLocale?: () => Promise;
/** Current app version from Electron `app.getVersion()`. */
getAppVersion?: () => Promise;
+ feedbackGetStatus?: () => Promise;
+ feedbackSubmit?: (request: FeedbackSubmitRequest) => Promise;
/**
* OpenRouter reference/retail prices keyed by normalized model id/name
* (USD per million tokens). Used to render the struck-through baseline on
diff --git a/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss b/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss
index 04b7427d9..df7cd2715 100644
--- a/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss
+++ b/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss
@@ -77,6 +77,20 @@
color: var(--danger);
}
+.warning .icon,
+.warning .title {
+ color: rgb(var(--warning-rgb));
+}
+
+.toast.warning {
+ border-color: rgba(var(--warning-rgb), 0.32);
+}
+
+.toast.warning .icon {
+ background: rgba(var(--warning-rgb), 0.12);
+ color: rgb(var(--warning-rgb));
+}
+
.text {
display: flex;
flex: 1 1 auto;
@@ -133,6 +147,15 @@
background: rgba(var(--danger-rgb), 0.2);
}
+.warning .action {
+ background: rgba(var(--warning-rgb), 0.12);
+ color: rgb(var(--warning-rgb));
+}
+
+.warning .action:hover {
+ background: rgba(var(--warning-rgb), 0.2);
+}
+
.dismiss {
flex: 0 0 auto;
display: inline-flex;
diff --git a/apps/desktop/src/renderer/ui/components/BottomNotice.tsx b/apps/desktop/src/renderer/ui/components/BottomNotice.tsx
index 8b5e312f0..97bcd2395 100644
--- a/apps/desktop/src/renderer/ui/components/BottomNotice.tsx
+++ b/apps/desktop/src/renderer/ui/components/BottomNotice.tsx
@@ -20,7 +20,7 @@ interface Props {
priority?: 'default' | 'overlay';
role?: 'alert' | 'status';
title?: ReactNode;
- tone?: 'danger' | 'success';
+ tone?: 'danger' | 'success' | 'warning';
}
export function BottomNotice({
diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss b/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss
new file mode 100644
index 000000000..b2e8caf20
--- /dev/null
+++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss
@@ -0,0 +1,278 @@
+.modal {
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ background: var(--bg-card);
+ box-shadow: var(--shadow-lg, 0 24px 72px rgba(0, 0, 0, 0.38));
+
+ :global(.as-modal__header) {
+ padding: 24px 24px 18px;
+ border-bottom: none;
+ }
+
+ :global(.as-modal__title) {
+ color: var(--text-primary);
+ font-size: 16px;
+ font-weight: 500;
+ }
+
+ :global(.as-modal__close) {
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 9px;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ }
+
+ :global(.as-modal__close):hover {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+ }
+}
+.modalBody {
+ padding: 0;
+}
+
+.dropZone {
+ position: relative;
+}
+
+.dropOverlay {
+ position: absolute;
+ z-index: 4;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ border: 2px dashed var(--accent-green);
+ border-radius: 0 0 20px 20px;
+ background: rgba(var(--accent-green-rgb), 0.1);
+ color: var(--accent-green);
+ font-size: 14px;
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.form {
+ display: flex;
+ flex-direction: column;
+}
+
+.fields {
+ display: grid;
+ gap: 16px;
+ padding: 18px 24px 24px;
+}
+
+.textarea,
+.email {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font: inherit;
+ outline: none;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.textarea {
+ min-height: 132px;
+ padding: 16px;
+ resize: none;
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+.email {
+ height: 48px;
+ padding: 0 14px;
+ font-size: 14px;
+}
+
+.textarea::placeholder,
+.email::placeholder {
+ color: var(--text-faint);
+}
+
+.textarea:focus,
+.email:focus {
+ border-color: var(--text-muted);
+ box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.18);
+}
+
+.inputError {
+ border-color: var(--danger);
+}
+
+.diagnosticsRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.checkboxLabel {
+ display: flex;
+ grid-template-columns: none;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-primary);
+ font-size: 14px;
+ letter-spacing: 0;
+ cursor: pointer;
+}
+
+.checkboxLabel input {
+ width: 18px;
+ height: 18px;
+ margin: 0;
+ accent-color: var(--accent-green);
+}
+
+.infoButton {
+ display: inline-grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: help;
+}
+
+.attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding: 10px;
+ border: 1px dashed var(--border);
+ border-radius: 12px;
+}
+
+.thumbnail {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-primary);
+}
+
+.thumbnail img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.thumbnail button {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ border: none;
+ background: rgba(0, 0, 0, 0.58);
+ color: white;
+ font-size: 24px;
+ opacity: 0;
+ cursor: pointer;
+ transition: opacity 0.15s ease;
+}
+
+.thumbnail:hover button,
+.thumbnail button:focus-visible {
+ opacity: 1;
+}
+
+.footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 24px;
+ border-top: 1px solid var(--border);
+ border-radius: 0 0 20px 20px;
+ background: var(--bg-secondary);
+}
+
+.fileInput,
+.srOnly {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.attachButton,
+.submitButton {
+ min-height: 42px;
+}
+
+.submitButton {
+ min-width: 174px;
+}
+
+.submitButton kbd {
+ display: inline-grid;
+ place-items: center;
+ min-width: 23px;
+ height: 23px;
+ margin-left: 5px;
+ padding: 0 5px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.08);
+ color: currentColor;
+ font: inherit;
+ font-size: 11px;
+}
+
+.spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ margin-right: 7px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ vertical-align: -2px;
+ animation: feedback-spin 0.7s linear infinite;
+}
+
+@keyframes feedback-spin {
+ to { transform: rotate(360deg); }
+}
+
+.fieldError,
+.submitError,
+.unavailable {
+ margin: -9px 0 0;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.fieldError,
+.submitError {
+ color: var(--danger);
+}
+
+.unavailable {
+ color: var(--text-muted);
+}
+
+@media (max-width: 620px) {
+ .footer {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .attachButton,
+ .submitButton {
+ width: 100%;
+ }
+}
diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx b/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx
new file mode 100644
index 000000000..e745e0e79
--- /dev/null
+++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx
@@ -0,0 +1,59 @@
+import assert from 'node:assert/strict';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { test } from 'vitest';
+import {
+ FeedbackModal,
+ isFeedbackSubmitShortcut,
+ validateFeedbackAttachmentSelection,
+ validateFeedbackEmail,
+} from './FeedbackModal';
+import {
+ FEEDBACK_MAX_ATTACHMENT_BYTES,
+ FEEDBACK_MAX_IMAGES,
+} from '../../../shared/feedback';
+
+test('validates optional contact email', () => {
+ assert.equal(validateFeedbackEmail(''), null);
+ assert.equal(validateFeedbackEmail('person@example.com'), null);
+ assert.match(validateFeedbackEmail('not-an-email') ?? '', /valid contact email/);
+});
+
+test('validates attachment type, count, and total size', () => {
+ assert.match(validateFeedbackAttachmentSelection([], [{ size: 1, type: 'image/gif' }]) ?? '', /JPEG/);
+ assert.match(validateFeedbackAttachmentSelection(
+ Array.from({ length: FEEDBACK_MAX_IMAGES }, () => ({ size: 1 })),
+ [{ size: 1, type: 'image/png' }],
+ ) ?? '', /no more than 10/);
+ assert.match(validateFeedbackAttachmentSelection([], [{
+ size: FEEDBACK_MAX_ATTACHMENT_BYTES + 1,
+ type: 'image/webp',
+ }]) ?? '', /8 MiB/);
+ assert.equal(validateFeedbackAttachmentSelection([], [{ size: 1, type: 'image/jpeg' }]), null);
+});
+
+test('recognizes enabled Cmd/Ctrl+Enter submission shortcuts', () => {
+ assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Enter', metaKey: false }, true), true);
+ assert.equal(isFeedbackSubmitShortcut({ ctrlKey: false, key: 'Enter', metaKey: true }, true), true);
+ assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Enter', metaKey: false }, false), false);
+ assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Space', metaKey: false }, true), false);
+});
+
+test('renders the accessible Emdash-inspired feedback form', () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ onSubmitted={() => {}}
+ />,
+ );
+ assert.match(markup, /role="dialog"/);
+ assert.match(markup, /aria-labelledby="[^"]+"/);
+ assert.match(markup, /]*>Feedback<\/h2>/);
+ assert.match(markup, /Feedback details/);
+ assert.match(markup, /Contact email/);
+ assert.match(markup, /Include diagnostic logs/);
+ assert.match(markup, /Attach image/);
+ assert.match(markup, /Send Feedback/);
+ assert.match(markup, /disabled=""/);
+});
diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx b/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx
new file mode 100644
index 000000000..cbc76dfa9
--- /dev/null
+++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx
@@ -0,0 +1,315 @@
+import { useCallback, useEffect, useRef, useState, type DragEvent, type FormEvent, type KeyboardEvent } from 'react';
+import { Attachment02Icon, InformationCircleIcon } from '@hugeicons/core-free-icons';
+import { HugeiconsIcon } from '@hugeicons/react';
+import { Button, Modal } from '@antseed/ui';
+import {
+ FEEDBACK_IMAGE_MIME_TYPES,
+ FEEDBACK_MAX_ATTACHMENT_BYTES,
+ FEEDBACK_MAX_EMAIL_LENGTH,
+ FEEDBACK_MAX_IMAGES,
+ FEEDBACK_MAX_TEXT_LENGTH,
+ type FeedbackImageInput,
+ type FeedbackImageMimeType,
+ type FeedbackSubmitResult,
+} from '../../../shared/feedback';
+import { InfoTooltip } from './InfoTooltip';
+import styles from './FeedbackModal.module.scss';
+
+type FeedbackAttachment = FeedbackImageInput & {
+ id: string;
+ previewUrl: string;
+};
+
+type FeedbackModalProps = {
+ configured: boolean;
+ isOpen: boolean;
+ onClose: () => void;
+ onSubmitted: (result: FeedbackSubmitResult) => void;
+};
+
+const ACCEPTED_MIME_TYPES = new Set(FEEDBACK_IMAGE_MIME_TYPES);
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+type FeedbackAttachmentCandidate = Pick;
+type FeedbackFileCandidate = Pick;
+
+export function validateFeedbackEmail(value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+ if (trimmed.length > FEEDBACK_MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(trimmed)) {
+ return 'Enter a valid contact email or leave it blank.';
+ }
+ return null;
+}
+
+export function validateFeedbackAttachmentSelection(
+ attachments: readonly FeedbackAttachmentCandidate[],
+ files: readonly FeedbackFileCandidate[],
+): string | null {
+ if (files.some((file) => !ACCEPTED_MIME_TYPES.has(file.type))) {
+ return 'Attach JPEG, PNG, or WebP images only.';
+ }
+ if (attachments.length + files.length > FEEDBACK_MAX_IMAGES) {
+ return `Attach no more than ${FEEDBACK_MAX_IMAGES} images.`;
+ }
+ const totalBytes = [...attachments, ...files].reduce((total, file) => total + file.size, 0);
+ if (totalBytes > FEEDBACK_MAX_ATTACHMENT_BYTES) {
+ return 'Attachments exceed the 8 MiB total limit.';
+ }
+ return null;
+}
+
+export function isFeedbackSubmitShortcut(
+ event: Pick, 'ctrlKey' | 'key' | 'metaKey'>,
+ canSubmit: boolean,
+): boolean {
+ return canSubmit && (event.metaKey || event.ctrlKey) && event.key === 'Enter';
+}
+
+function encodeBase64(bytes: Uint8Array): string {
+ const chunkSize = 0x8000;
+ let binary = '';
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
+ }
+ return btoa(binary);
+}
+
+async function prepareAttachment(file: File): Promise {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const dataBase64 = encodeBase64(bytes);
+ return {
+ id: `${file.name}-${file.size}-${file.lastModified}-${crypto.randomUUID()}`,
+ name: file.name,
+ mimeType: file.type as FeedbackImageMimeType,
+ size: file.size,
+ dataBase64,
+ previewUrl: `data:${file.type};base64,${dataBase64}`,
+ };
+}
+
+export function FeedbackModal({ configured, isOpen, onClose, onSubmitted }: FeedbackModalProps) {
+ const [feedback, setFeedback] = useState('');
+ const [contactEmail, setContactEmail] = useState('');
+ const [includeDiagnosticLogs, setIncludeDiagnosticLogs] = useState(false);
+ const [attachments, setAttachments] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+ const [contactEmailError, setContactEmailError] = useState(null);
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const reset = useCallback(() => {
+ setFeedback('');
+ setContactEmail('');
+ setIncludeDiagnosticLogs(false);
+ setAttachments([]);
+ setSubmitting(false);
+ setError(null);
+ setContactEmailError(null);
+ setIsDraggingOver(false);
+ }, []);
+
+ useEffect(() => {
+ if (isOpen) reset();
+ }, [isOpen, reset]);
+
+ const addFiles = useCallback(async (files: File[]) => {
+ if (submitting || files.length === 0) return;
+ const selectionError = validateFeedbackAttachmentSelection(attachments, files);
+ if (selectionError) {
+ setError(selectionError);
+ return;
+ }
+ try {
+ const prepared = await Promise.all(files.map(prepareAttachment));
+ setAttachments((current) => [...current, ...prepared]);
+ setError(null);
+ } catch {
+ setError('Unable to read one of the selected images.');
+ }
+ }, [attachments, submitting]);
+
+ const handleSubmit = useCallback(async (event: FormEvent) => {
+ event.preventDefault();
+ const emailError = validateFeedbackEmail(contactEmail);
+ if (emailError) {
+ setContactEmailError(emailError);
+ return;
+ }
+ if (!configured) {
+ setError('Feedback is unavailable in this build.');
+ return;
+ }
+ const bridge = window.antseedDesktop?.feedbackSubmit;
+ if (!bridge) {
+ setError('Feedback is unavailable in this build.');
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ const result = await bridge({
+ feedback,
+ contactEmail,
+ images: attachments.map(({ previewUrl: _previewUrl, id: _id, ...attachment }) => attachment),
+ includeDiagnosticLogs,
+ });
+ if (!result.ok) {
+ setError(result.error ?? 'Unable to send feedback. Please try again.');
+ return;
+ }
+ onSubmitted(result);
+ } catch {
+ setError('Unable to send feedback. Please try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ }, [attachments, configured, contactEmail, feedback, includeDiagnosticLogs, onSubmitted]);
+
+ const canSubmit = configured && feedback.trim().length > 0 && !submitting;
+ const shortcutModifier = typeof window !== 'undefined' && window.antseedDesktop?.platform === 'darwin'
+ ? '⌘'
+ : 'Ctrl';
+
+ const handleFormKeyDown = useCallback((event: KeyboardEvent) => {
+ if (isFeedbackSubmitShortcut(event, canSubmit)) {
+ event.preventDefault();
+ event.currentTarget.requestSubmit();
+ }
+ }, [canSubmit]);
+
+ const handleDrop = useCallback((event: DragEvent) => {
+ event.preventDefault();
+ setIsDraggingOver(false);
+ void addFiles(Array.from(event.dataTransfer.files));
+ }, [addFiles]);
+
+ return (
+ {} : onClose}
+ size="lg"
+ title="Feedback"
+ >
+ { event.preventDefault(); setIsDraggingOver(true); }}
+ onDragLeave={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsDraggingOver(false);
+ }}
+ onDragOver={(event) => event.preventDefault()}
+ onDrop={handleDrop}
+ >
+ {isDraggingOver &&
Drop images here
}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx b/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx
index 0793ca8cd..2ad74d01d 100644
--- a/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx
+++ b/apps/desktop/src/renderer/ui/components/views/VprHelpView.tsx
@@ -1,16 +1,22 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { HugeiconsIcon } from '@hugeicons/react';
import {
+ AlertCircleIcon,
ArrowRight01Icon,
ArrowUpRight01Icon,
+ ChatFeedback01Icon,
+ CheckmarkCircle01Icon,
Copy01Icon,
GithubIcon,
NewTwitterIcon,
TelegramIcon,
} from '@hugeicons/core-free-icons';
+import type { FeedbackStatus, FeedbackSubmitResult } from '../../../../shared/feedback';
import { getUiStateRef } from '../../../core/store';
import { shallowEqual, useUiSelector } from '../../hooks/useUiSelector';
import { useActions } from '../../hooks/useActions';
+import { BottomNotice } from '../BottomNotice';
+import { FeedbackModal } from '../FeedbackModal';
import { VprCard, VprPage, VprSettingRow, VprToggle } from '../vpr/VprKit';
import styles from './VprHelpView.module.scss';
@@ -665,10 +671,39 @@ export function VprHelpView({ onSelectView }: Props) {
const [appVersion, setAppVersion] = useState(
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '',
);
+ const [feedbackOpen, setFeedbackOpen] = useState(false);
+ const [feedbackStatus, setFeedbackStatus] = useState(null);
+ const [feedbackNotice, setFeedbackNotice] = useState<{
+ body: string;
+ tone: 'success' | 'warning';
+ } | null>(null);
useEffect(() => {
window.antseedDesktop?.getAppVersion?.().then(setAppVersion).catch(() => {});
+ const getFeedbackStatus = window.antseedDesktop?.feedbackGetStatus;
+ if (!getFeedbackStatus) {
+ setFeedbackStatus({ configured: false, error: 'Feedback is unavailable in this build.' });
+ return;
+ }
+ void getFeedbackStatus()
+ .then(setFeedbackStatus)
+ .catch(() => setFeedbackStatus({ configured: false, error: 'Feedback is unavailable in this build.' }));
}, []);
+ useEffect(() => {
+ if (!feedbackNotice) return undefined;
+ const timer = window.setTimeout(() => setFeedbackNotice(null), 5_000);
+ return () => window.clearTimeout(timer);
+ }, [feedbackNotice]);
+
+ function handleFeedbackSubmitted(result: FeedbackSubmitResult): void {
+ setFeedbackOpen(false);
+ if (result.attachmentWarnings?.length) {
+ setFeedbackNotice({ body: result.attachmentWarnings.join('\n'), tone: 'warning' });
+ return;
+ }
+ setFeedbackNotice({ body: 'Thanks — your feedback was sent.', tone: 'success' });
+ }
+
// Developer mode is the single switch for diagnostics: it enables debug
// logging (settings module) and reveals the diagnostic screens below.
function toggleDevMode(next: boolean): void {
@@ -790,6 +825,26 @@ export function VprHelpView({ onSelectView }: Props) {
Get help from the team and the community, and follow what we ship.
+