@oar-sh/oar 0.9.19 → 0.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-control-state.mjs +12 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +16 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +20 -2
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: tokens per turn for every provider, and their daily sums
|
|
5
|
+
* (services/token-usage-service.mjs, the Tokens sections in Check Usage and
|
|
6
|
+
* the About panel).
|
|
7
|
+
*
|
|
8
|
+
* `turn_token_usage`: one row per turn, run and model, kept with the chat
|
|
9
|
+
* and deleted with it.
|
|
10
|
+
* - `conversation_id`; `turn_id`: the queue row id (the user message's id);
|
|
11
|
+
* no foreign key, as queue rows are pruned.
|
|
12
|
+
* - `run_id`: the worker process or attempt ('' for none), so a re-run after
|
|
13
|
+
* a crash keeps the first attempt's row.
|
|
14
|
+
* - `model`: lower case, without `[1m]`. `provider`: the relay's provider
|
|
15
|
+
* kind. `billing`: '' or `byok-<provider type>` (Copilot's own key).
|
|
16
|
+
* - `native_session_id`, `response_message_id`: NULL when not known.
|
|
17
|
+
* - `at`: the turn's first report (ISO); `day`: its relay local date
|
|
18
|
+
* (`YYYY-MM-DD`), fixed at the first report.
|
|
19
|
+
* - `input_tokens` (fresh input, no cache), `output_tokens`,
|
|
20
|
+
* `cache_write_tokens`, `cache_read_tokens`, `reasoning_tokens` (part of
|
|
21
|
+
* output): NULL when the provider did not report the field; 0 when it
|
|
22
|
+
* reported zero.
|
|
23
|
+
* - `cost_usd` (NULL when not known), `cost_source` (`claude-code`,
|
|
24
|
+
* `provider`, `oar-deepseek-table`), `cost_unknown`.
|
|
25
|
+
*
|
|
26
|
+
* `token_usage_days`: the sums per day, provider, billing, model and source
|
|
27
|
+
* (`turns`, or `backfill` for the copy below). Its rows survive the chats.
|
|
28
|
+
* A token field stays NULL until some turn reports it, then it is a sum.
|
|
29
|
+
*
|
|
30
|
+
* Backfill, in the transaction that creates the tables (a `token_usage_days`
|
|
31
|
+
* table that exists means it ran): the Claude API totals (0034, local days)
|
|
32
|
+
* and DeepSeek's days (0025, UTC days, kept as they are) are copied with
|
|
33
|
+
* `source='backfill'`. Their `miss_tokens` become `input_tokens` (cache
|
|
34
|
+
* writes included: the views say so), `hit_tokens` `cache_read_tokens`, and
|
|
35
|
+
* `cache_write_tokens` stays NULL. Model ids are normalised as above, and
|
|
36
|
+
* rows that meet on one key are summed. DeepSeek's `cost_usd` is the token
|
|
37
|
+
* estimate alone (its tools cost stays on the DeepSeek card; a NULL stays
|
|
38
|
+
* NULL), and its days keep no seen times, so the day's bounds stand in. A
|
|
39
|
+
* source that 0034's or 0025's own check finds missing or partial (its
|
|
40
|
+
* migration failed this boot) makes this one throw, so the schema boot logs
|
|
41
|
+
* it and retries on the next boot once the source is there.
|
|
42
|
+
*
|
|
43
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
44
|
+
*
|
|
45
|
+
* Usage: node server/migrations/0036-token-usage.mjs [path/to/oar.db]
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { fileURLToPath } from 'url';
|
|
49
|
+
import path from 'path';
|
|
50
|
+
import process from 'process';
|
|
51
|
+
import Database from 'better-sqlite3';
|
|
52
|
+
import { cliDbPath } from '../services/database-file.mjs';
|
|
53
|
+
|
|
54
|
+
import { CLAUDE_API_USAGE_TOTALS_TABLE, claudeApiUsageTotalsProblem } from './0034-claude-api-usage-totals.mjs';
|
|
55
|
+
import { DEEPSEEK_USAGE_DAYS_TABLE, deepseekUsageRequestsDailyProblem } from './0025-deepseek-usage-requests-daily.mjs';
|
|
56
|
+
|
|
57
|
+
export const TURN_TOKEN_USAGE_TABLE = 'turn_token_usage';
|
|
58
|
+
export const TOKEN_USAGE_DAYS_TABLE = 'token_usage_days';
|
|
59
|
+
export const TURN_TOKEN_USAGE_INDEXES = Object.freeze([
|
|
60
|
+
'idx_turn_token_usage_conversation',
|
|
61
|
+
'idx_turn_token_usage_native_session',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export const TURN_TOKEN_USAGE_COLUMNS = Object.freeze([
|
|
65
|
+
'conversation_id',
|
|
66
|
+
'turn_id',
|
|
67
|
+
'run_id',
|
|
68
|
+
'model',
|
|
69
|
+
'provider',
|
|
70
|
+
'billing',
|
|
71
|
+
'native_session_id',
|
|
72
|
+
'response_message_id',
|
|
73
|
+
'at',
|
|
74
|
+
'day',
|
|
75
|
+
'input_tokens',
|
|
76
|
+
'output_tokens',
|
|
77
|
+
'cache_write_tokens',
|
|
78
|
+
'cache_read_tokens',
|
|
79
|
+
'reasoning_tokens',
|
|
80
|
+
'cost_usd',
|
|
81
|
+
'cost_source',
|
|
82
|
+
'cost_unknown',
|
|
83
|
+
'updated_at',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export const TOKEN_USAGE_DAYS_COLUMNS = Object.freeze([
|
|
87
|
+
'day',
|
|
88
|
+
'provider',
|
|
89
|
+
'billing',
|
|
90
|
+
'model',
|
|
91
|
+
'source',
|
|
92
|
+
'input_tokens',
|
|
93
|
+
'output_tokens',
|
|
94
|
+
'cache_write_tokens',
|
|
95
|
+
'cache_read_tokens',
|
|
96
|
+
'reasoning_tokens',
|
|
97
|
+
'cost_usd',
|
|
98
|
+
'unknown_cost_tokens',
|
|
99
|
+
'turn_count',
|
|
100
|
+
'first_seen_at',
|
|
101
|
+
'last_seen_at',
|
|
102
|
+
'updated_at',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function tableExists(db, name) {
|
|
106
|
+
return !!db
|
|
107
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
108
|
+
.get(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function missingColumns(db, table, expected) {
|
|
112
|
+
const present = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
|
113
|
+
return expected.filter((name) => !present.has(name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What keeps the service from using the tables: '' when nothing does. */
|
|
117
|
+
export function tokenUsageProblem(db) {
|
|
118
|
+
for (const [table, expected] of [
|
|
119
|
+
[TURN_TOKEN_USAGE_TABLE, TURN_TOKEN_USAGE_COLUMNS],
|
|
120
|
+
[TOKEN_USAGE_DAYS_TABLE, TOKEN_USAGE_DAYS_COLUMNS],
|
|
121
|
+
]) {
|
|
122
|
+
if (!tableExists(db, table)) return `no ${table} table`;
|
|
123
|
+
const missing = missingColumns(db, table, expected);
|
|
124
|
+
if (missing.length) return `${table} lacks ${missing.join(', ')}`;
|
|
125
|
+
}
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The tables' model id: lower case, without the `[1m]` suffix
|
|
130
|
+
// (shared/turn-token-usage.mjs), in SQL.
|
|
131
|
+
const BOOKED_MODEL_SQL = `CASE WHEN LOWER(TRIM(model)) LIKE '%[1m]' THEN SUBSTR(LOWER(TRIM(model)), 1, LENGTH(TRIM(model)) - 4) ELSE LOWER(TRIM(model)) END`;
|
|
132
|
+
|
|
133
|
+
// Two source rows can land on one key once their model ids are normalised:
|
|
134
|
+
// they are summed, null-aware.
|
|
135
|
+
const BACKFILL_UPSERT = `
|
|
136
|
+
ON CONFLICT (day, provider, billing, model, source) DO UPDATE SET
|
|
137
|
+
input_tokens = CASE WHEN excluded.input_tokens IS NULL THEN input_tokens ELSE COALESCE(input_tokens, 0) + excluded.input_tokens END,
|
|
138
|
+
output_tokens = CASE WHEN excluded.output_tokens IS NULL THEN output_tokens ELSE COALESCE(output_tokens, 0) + excluded.output_tokens END,
|
|
139
|
+
cache_read_tokens = CASE WHEN excluded.cache_read_tokens IS NULL THEN cache_read_tokens ELSE COALESCE(cache_read_tokens, 0) + excluded.cache_read_tokens END,
|
|
140
|
+
cost_usd = CASE WHEN excluded.cost_usd IS NULL THEN cost_usd ELSE COALESCE(cost_usd, 0) + excluded.cost_usd END,
|
|
141
|
+
unknown_cost_tokens = unknown_cost_tokens + excluded.unknown_cost_tokens,
|
|
142
|
+
turn_count = turn_count + excluded.turn_count,
|
|
143
|
+
first_seen_at = MIN(first_seen_at, excluded.first_seen_at),
|
|
144
|
+
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)
|
|
145
|
+
`;
|
|
146
|
+
|
|
147
|
+
export function migrateTokenUsage(db, { now = () => new Date().toISOString() } = {}) {
|
|
148
|
+
return db.transaction(() => {
|
|
149
|
+
let tablesAdded = 0;
|
|
150
|
+
const backfilled = { claudeApi: 0, deepseek: 0 };
|
|
151
|
+
// Done means both tables exist, checked inside the transaction.
|
|
152
|
+
if (tableExists(db, TOKEN_USAGE_DAYS_TABLE) && tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
|
|
153
|
+
return { applied: false, tablesAdded, backfilled };
|
|
154
|
+
}
|
|
155
|
+
if (!tableExists(db, TURN_TOKEN_USAGE_TABLE)) {
|
|
156
|
+
db.exec(`
|
|
157
|
+
CREATE TABLE ${TURN_TOKEN_USAGE_TABLE} (
|
|
158
|
+
conversation_id TEXT NOT NULL,
|
|
159
|
+
turn_id TEXT NOT NULL,
|
|
160
|
+
run_id TEXT NOT NULL DEFAULT '',
|
|
161
|
+
model TEXT NOT NULL,
|
|
162
|
+
provider TEXT NOT NULL,
|
|
163
|
+
billing TEXT NOT NULL DEFAULT '',
|
|
164
|
+
native_session_id TEXT,
|
|
165
|
+
response_message_id TEXT,
|
|
166
|
+
at TEXT NOT NULL,
|
|
167
|
+
day TEXT NOT NULL,
|
|
168
|
+
input_tokens INTEGER,
|
|
169
|
+
output_tokens INTEGER,
|
|
170
|
+
cache_write_tokens INTEGER,
|
|
171
|
+
cache_read_tokens INTEGER,
|
|
172
|
+
reasoning_tokens INTEGER,
|
|
173
|
+
cost_usd REAL,
|
|
174
|
+
cost_source TEXT,
|
|
175
|
+
cost_unknown INTEGER NOT NULL DEFAULT 0,
|
|
176
|
+
updated_at TEXT NOT NULL,
|
|
177
|
+
PRIMARY KEY (turn_id, run_id, model)
|
|
178
|
+
);
|
|
179
|
+
CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[0]} ON ${TURN_TOKEN_USAGE_TABLE} (conversation_id, at);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS ${TURN_TOKEN_USAGE_INDEXES[1]} ON ${TURN_TOKEN_USAGE_TABLE} (native_session_id);
|
|
181
|
+
`);
|
|
182
|
+
tablesAdded += 1;
|
|
183
|
+
}
|
|
184
|
+
if (!tableExists(db, TOKEN_USAGE_DAYS_TABLE)) {
|
|
185
|
+
// The copy needs both sources as 0034 and 0025 leave them; a missing or
|
|
186
|
+
// partial one (its migration failed this boot) waits for the next boot.
|
|
187
|
+
for (const problem of [claudeApiUsageTotalsProblem(db), deepseekUsageRequestsDailyProblem(db)]) {
|
|
188
|
+
if (problem) throw new Error(`cannot backfill yet: ${problem}`);
|
|
189
|
+
}
|
|
190
|
+
db.exec(`
|
|
191
|
+
CREATE TABLE ${TOKEN_USAGE_DAYS_TABLE} (
|
|
192
|
+
day TEXT NOT NULL,
|
|
193
|
+
provider TEXT NOT NULL,
|
|
194
|
+
billing TEXT NOT NULL DEFAULT '',
|
|
195
|
+
model TEXT NOT NULL,
|
|
196
|
+
source TEXT NOT NULL,
|
|
197
|
+
input_tokens INTEGER,
|
|
198
|
+
output_tokens INTEGER,
|
|
199
|
+
cache_write_tokens INTEGER,
|
|
200
|
+
cache_read_tokens INTEGER,
|
|
201
|
+
reasoning_tokens INTEGER,
|
|
202
|
+
cost_usd REAL,
|
|
203
|
+
unknown_cost_tokens INTEGER NOT NULL DEFAULT 0,
|
|
204
|
+
turn_count INTEGER NOT NULL DEFAULT 0,
|
|
205
|
+
first_seen_at TEXT NOT NULL,
|
|
206
|
+
last_seen_at TEXT NOT NULL,
|
|
207
|
+
updated_at TEXT NOT NULL,
|
|
208
|
+
PRIMARY KEY (day, provider, billing, model, source)
|
|
209
|
+
)
|
|
210
|
+
`);
|
|
211
|
+
tablesAdded += 1;
|
|
212
|
+
const at = now();
|
|
213
|
+
const columns = `
|
|
214
|
+
day, provider, billing, model, source,
|
|
215
|
+
input_tokens, output_tokens, cache_write_tokens, cache_read_tokens, reasoning_tokens,
|
|
216
|
+
cost_usd, unknown_cost_tokens, turn_count, first_seen_at, last_seen_at, updated_at
|
|
217
|
+
`;
|
|
218
|
+
// `WHERE true`: SQLite's parser needs it before an upsert on a SELECT.
|
|
219
|
+
backfilled.claudeApi = db.prepare(`
|
|
220
|
+
INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
|
|
221
|
+
SELECT day, 'claude-api', '', ${BOOKED_MODEL_SQL}, 'backfill',
|
|
222
|
+
miss_tokens, output_tokens, NULL, hit_tokens, NULL,
|
|
223
|
+
cost_usd, COALESCE(unknown_cost_tokens, 0), COALESCE(turn_count, 0), first_seen_at, last_seen_at, ?
|
|
224
|
+
FROM ${CLAUDE_API_USAGE_TOTALS_TABLE} WHERE true
|
|
225
|
+
${BACKFILL_UPSERT}
|
|
226
|
+
`).run(at).changes;
|
|
227
|
+
// DeepSeek's days keep no seen times: the day's start and end stand in
|
|
228
|
+
// (UTC days). Its token estimate only, in dollars; no unknown tokens.
|
|
229
|
+
backfilled.deepseek = db.prepare(`
|
|
230
|
+
INSERT INTO ${TOKEN_USAGE_DAYS_TABLE} (${columns})
|
|
231
|
+
SELECT day, 'deepseek', '', ${BOOKED_MODEL_SQL}, 'backfill',
|
|
232
|
+
miss_tokens, output_tokens, NULL, hit_tokens, NULL,
|
|
233
|
+
cost_usd, 0, COALESCE(turn_count, 0), day || 'T00:00:00.000Z', day || 'T23:59:59.999Z', ?
|
|
234
|
+
FROM ${DEEPSEEK_USAGE_DAYS_TABLE} WHERE true
|
|
235
|
+
${BACKFILL_UPSERT}
|
|
236
|
+
`).run(at).changes;
|
|
237
|
+
}
|
|
238
|
+
return { applied: true, tablesAdded, backfilled };
|
|
239
|
+
})();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function migrate(dbPath, options = {}) {
|
|
243
|
+
const db = new Database(dbPath, options);
|
|
244
|
+
try {
|
|
245
|
+
return migrateTokenUsage(db);
|
|
246
|
+
} finally {
|
|
247
|
+
db.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
252
|
+
if (isMain) {
|
|
253
|
+
try {
|
|
254
|
+
// The given path, else the database in server/data (oar.db, else the
|
|
255
|
+
// name before 0.9.20); never a new one.
|
|
256
|
+
const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
|
|
257
|
+
const result = migrate(dbPath);
|
|
258
|
+
console.log(`Migration 0036 on ${dbPath}`);
|
|
259
|
+
console.log(result.applied
|
|
260
|
+
? ` token usage: tables added ${result.tablesAdded}, backfilled days: claude api ${result.backfilled.claudeApi}, deepseek ${result.backfilled.deepseek}`
|
|
261
|
+
: ' skipped: nothing to change');
|
|
262
|
+
process.exit(0);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error(`Migration 0036 failed: ${err?.message || err}`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -13,9 +13,18 @@ const STATE_LABELS = Object.freeze({
|
|
|
13
13
|
|
|
14
14
|
const USAGE_LABELS = Object.freeze({
|
|
15
15
|
github: 'GitHub Copilot',
|
|
16
|
+
claude: 'Claude',
|
|
17
|
+
'claude-api': 'Claude API',
|
|
18
|
+
'claude-cloud': 'Claude Cloud',
|
|
16
19
|
cursor: 'Cursor',
|
|
17
20
|
grok: 'Grok',
|
|
18
|
-
|
|
21
|
+
deepseek: 'DeepSeek',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const OWN_KEY_LABELS = Object.freeze({
|
|
25
|
+
'byok-openai': 'OpenAI',
|
|
26
|
+
'byok-anthropic': 'Anthropic',
|
|
27
|
+
'byok-azure': 'Azure OpenAI',
|
|
19
28
|
});
|
|
20
29
|
|
|
21
30
|
function esc(value) {
|
|
@@ -133,11 +142,67 @@ function providerState(provider) {
|
|
|
133
142
|
return `<span class="about-state" data-state="${esc(state)}">${esc(STATE_LABELS[state])}</span>${esc(count)}`;
|
|
134
143
|
}
|
|
135
144
|
|
|
145
|
+
// A token count of the token booking: "—" where no turn reported it.
|
|
146
|
+
function knownCount(value) {
|
|
147
|
+
return value === null || value === undefined || value === '' ? null : finite(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tokenAmount(value) {
|
|
151
|
+
return knownCount(value) === null ? '—' : formatAmount(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* "Oct 29, 2026" for a `YYYY-MM-DD` day, as Check Usage writes it, joined
|
|
156
|
+
* by no-break spaces so a phone never wraps inside it.
|
|
157
|
+
*/
|
|
158
|
+
export function aboutDayLabel(day) {
|
|
159
|
+
const date = new Date(`${day}T00:00:00.000Z`);
|
|
160
|
+
if (!day || Number.isNaN(date.getTime())) return String(day || '');
|
|
161
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).replace(/ /g, '\u00a0');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The all-time tokens: their sum, and the four kinds with the first day.
|
|
166
|
+
* Days copied at the upgrade (Claude API, DeepSeek) hold cache writes inside
|
|
167
|
+
* input, and the hint says until when.
|
|
168
|
+
*/
|
|
169
|
+
function tokenRow(label, tokens) {
|
|
170
|
+
const fields = [tokens.inputTokens, tokens.outputTokens, tokens.cacheWriteTokens, tokens.cacheReadTokens];
|
|
171
|
+
const known = fields.map(knownCount).filter((value) => value !== null);
|
|
172
|
+
const total = known.length ? known.reduce((sum, value) => sum + value, 0) : null;
|
|
173
|
+
const parts = [`in ${tokenAmount(tokens.inputTokens)} · out ${tokenAmount(tokens.outputTokens)} · cache w ${tokenAmount(tokens.cacheWriteTokens)} · cache r ${tokenAmount(tokens.cacheReadTokens)}`];
|
|
174
|
+
if (tokens.since) parts.push(`since ${aboutDayLabel(tokens.since)}`);
|
|
175
|
+
if (tokens.backfilled) {
|
|
176
|
+
parts.push(tokens.firstTurnsDay
|
|
177
|
+
? `input includes cache writes before ${aboutDayLabel(tokens.firstTurnsDay)}`
|
|
178
|
+
: 'input includes cache writes (days from before the upgrade)');
|
|
179
|
+
}
|
|
180
|
+
// One unbroken part: on a phone the unit stays with its number (the
|
|
181
|
+
// section's values otherwise wrap anywhere, for paths).
|
|
182
|
+
return row(label, `<span class="plan-usage-detail-part">${esc(tokenAmount(total))}</span>`, { hint: parts.join(' · ') });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tokenRows(usage) {
|
|
186
|
+
const rows = [];
|
|
187
|
+
if (usage.tokens) rows.push(tokenRow('Tokens (all time)', usage.tokens));
|
|
188
|
+
for (const entry of Array.isArray(usage.ownKeyTokens) ? usage.ownKeyTokens : []) {
|
|
189
|
+
const key = OWN_KEY_LABELS[entry.billing] || String(entry.billing || '').replace(/^byok-/, '') || 'own';
|
|
190
|
+
rows.push(tokenRow(`Tokens (all time), own ${key} key`, entry));
|
|
191
|
+
}
|
|
192
|
+
return rows;
|
|
193
|
+
}
|
|
194
|
+
|
|
136
195
|
function usageRows(id, usage) {
|
|
196
|
+
return [...providerUsageRows(id, usage), ...tokenRows(usage)];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function providerUsageRows(id, usage) {
|
|
137
200
|
if (id === 'github') {
|
|
201
|
+
if (usage.premiumRequests === undefined) return [];
|
|
138
202
|
return [row('Premium requests', esc(formatAmount(usage.premiumRequests)), { hint: `over ${formatCount(usage.turns)} replies` })];
|
|
139
203
|
}
|
|
140
204
|
if (id === 'cursor') {
|
|
205
|
+
if (usage.totalTokens === undefined) return [];
|
|
141
206
|
return [
|
|
142
207
|
row('Tokens', esc(formatAmount(usage.totalTokens)), {
|
|
143
208
|
hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out · ${formatAmount((finite(usage.cacheReadTokens) || 0) + (finite(usage.cacheWriteTokens) || 0))} cache`,
|
|
@@ -146,12 +211,14 @@ function usageRows(id, usage) {
|
|
|
146
211
|
];
|
|
147
212
|
}
|
|
148
213
|
if (id === 'grok') {
|
|
214
|
+
if (usage.totalTokens === undefined) return [];
|
|
149
215
|
return [
|
|
150
216
|
row('Tokens', esc(formatAmount(usage.totalTokens)), { hint: `${formatAmount(usage.inputTokens)} in · ${formatAmount(usage.outputTokens)} out` }),
|
|
151
217
|
row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.turns)} turns` }),
|
|
152
218
|
];
|
|
153
219
|
}
|
|
154
220
|
if (id === 'claude-cloud') {
|
|
221
|
+
if (usage.costUsd === undefined) return [];
|
|
155
222
|
return [row('Cost', esc(formatUsd(usage.costUsd)), { hint: `over ${formatCount(usage.sessions)} cloud sessions` })];
|
|
156
223
|
}
|
|
157
224
|
return [];
|
|
@@ -173,6 +240,39 @@ function buildUpdateSection(update, formatDateTime) {
|
|
|
173
240
|
));
|
|
174
241
|
}
|
|
175
242
|
|
|
243
|
+
// The database backups: the last result (time, size or why not), how many of
|
|
244
|
+
// how many are kept, and the folder.
|
|
245
|
+
function backupsRow(backups, formatDateTime) {
|
|
246
|
+
const last = backups.lastResult || null;
|
|
247
|
+
let value = 'none yet';
|
|
248
|
+
if (last?.status === 'written') value = `${formatDateTime(last.at)} · ${formatBytes(last.bytes)}`;
|
|
249
|
+
else if (last?.status === 'skipped') value = `skipped ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
250
|
+
else if (last?.status === 'failed') value = `failed ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
251
|
+
else if (last?.status === 'busy') value = `busy ${formatDateTime(last.at)}: ${last.reason || ''}`;
|
|
252
|
+
const kept = `${formatCount(backups.kept)} kept of ${formatCount(backups.keep)}`;
|
|
253
|
+
const hint = backups.enabled === false
|
|
254
|
+
? `at start: off (dbBackups.enabled); ${kept}`
|
|
255
|
+
: [kept, backups.problem || backups.dir].filter(Boolean).join(' · ');
|
|
256
|
+
return row('Backups', esc(value), { hint });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The running workers and how many still run the code before the last deploy
|
|
261
|
+
* (the relay recycles those once idle): "14, all on this build", "14, 3 on an
|
|
262
|
+
* older build", or the bare count when the relay cannot tell. Empty when no
|
|
263
|
+
* worker runs, so the row is left out.
|
|
264
|
+
*/
|
|
265
|
+
export function workersBuildText(workers) {
|
|
266
|
+
const total = finite(workers?.total) || 0;
|
|
267
|
+
if (total <= 0) return '';
|
|
268
|
+
const count = formatCount(total);
|
|
269
|
+
// Number(null) is 0: an unknown count must not read as "all on this build".
|
|
270
|
+
const other = workers?.onOtherBuild === null || workers?.onOtherBuild === undefined ? null : finite(workers.onOtherBuild);
|
|
271
|
+
if (other === null) return count;
|
|
272
|
+
if (other <= 0) return total === 1 ? `${count}, on this build` : `${count}, all on this build`;
|
|
273
|
+
return `${count}, ${formatCount(other)} on an older build`;
|
|
274
|
+
}
|
|
275
|
+
|
|
176
276
|
/**
|
|
177
277
|
* @param {object} about the GET /api/about payload
|
|
178
278
|
* @param {object} [options]
|
|
@@ -187,8 +287,10 @@ export function buildAboutModalHtml(about = {}, {
|
|
|
187
287
|
|
|
188
288
|
const commit = String(about.commit || '').trim();
|
|
189
289
|
const install = about.installMethod === 'git-checkout' ? 'git checkout' : (about.installMethod === 'npm-global' ? 'npm package' : '');
|
|
290
|
+
const workersText = workersBuildText(about.workers);
|
|
190
291
|
sections.push(section('build', 'OAR', table([
|
|
191
292
|
row('Version', esc(about.version || '–'), install ? { hint: install } : {}),
|
|
293
|
+
...(workersText ? [row('Workers', esc(workersText))] : []),
|
|
192
294
|
...(commit ? [row('Commit', `<code>${esc(commit.slice(0, 7))}</code>`, { title: commit })] : []),
|
|
193
295
|
row('Started', esc(about.startedAt ? formatDateTime(about.startedAt) : '–')),
|
|
194
296
|
row('Uptime', esc(formatUptime(about.uptimeSeconds))),
|
|
@@ -217,13 +319,14 @@ export function buildAboutModalHtml(about = {}, {
|
|
|
217
319
|
sections.push(section('usage', 'Usage', (usageIds.length
|
|
218
320
|
? usageIds.map((id) => `<div class="about-usage-provider">${esc(USAGE_LABELS[id])}</div>${table(usageRows(id, usage[id]))}`).join('')
|
|
219
321
|
: note('No provider has reported usage yet.'))
|
|
220
|
-
+ note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session. Claude
|
|
322
|
+
+ note('What the providers report and this relay keeps: Copilot premium requests per reply, Cursor and Grok tokens and cost per billing cycle, Claude Cloud cost per session, and every provider\'s tokens of all time: fresh input, output, cache writes and cache reads, "—" where no turn reported one. On Claude API and DeepSeek the days from before the upgrade have cache writes inside input. The tokens stay when a chat is deleted; Check Usage shows them by day and model.')));
|
|
221
323
|
|
|
222
324
|
const relay = about.relay || {};
|
|
223
325
|
sections.push(section('relay', 'Relay', table([
|
|
224
326
|
row('Uptime', esc(formatUptime(relay.uptimeSeconds))),
|
|
225
|
-
row('Database', esc(formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
|
|
327
|
+
row('Database', esc(relay.dbPath ? `${relay.dbPath} · ${formatBytes(relay.dbBytes)}` : formatBytes(relay.dbBytes)), { hint: `${formatBytes(relay.dbFileBytes)} + ${formatBytes(relay.walBytes)} WAL` }),
|
|
226
328
|
row('Running workers', esc(formatCount(relay.runningWorkers))),
|
|
329
|
+
...(about.backups ? [backupsRow(about.backups, formatDateTime)] : []),
|
|
227
330
|
])));
|
|
228
331
|
|
|
229
332
|
sections.push(buildUpdateSection(about.update || {}, formatDateTime));
|
|
@@ -107,7 +107,7 @@ export async function confirmKillCurrentSession() {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
//
|
|
110
|
+
// ︙ → Restart web relay first shows what the restart would wait for and
|
|
111
111
|
// leave running (relay-restart-ui.js); its buttons land in
|
|
112
112
|
// confirmRestartWebRelay below or in confirmRestartRelayNow.
|
|
113
113
|
export { openRestartRelayConfirmation };
|
|
@@ -982,7 +982,9 @@ export async function loadConversation(id, options = {}) {
|
|
|
982
982
|
const aroundMessageId = String(options.aroundMessageId || '').trim();
|
|
983
983
|
if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
|
|
984
984
|
const query = params.toString();
|
|
985
|
-
|
|
985
|
+
// `onErrorResponse` tells a conversation the relay does not know (404) from a failed load.
|
|
986
|
+
const onErrorResponse = typeof options.onErrorResponse === 'function' ? options.onErrorResponse : undefined;
|
|
987
|
+
return apiFetch(`/api/conversation/${convId}${query ? `?${query}` : ''}`, onErrorResponse ? { onErrorResponse } : undefined);
|
|
986
988
|
}
|
|
987
989
|
|
|
988
990
|
export async function refreshConversationHistory(id, options = {}) {
|
|
@@ -1247,7 +1249,7 @@ export async function createConversationShareLink(id) {
|
|
|
1247
1249
|
});
|
|
1248
1250
|
}
|
|
1249
1251
|
|
|
1250
|
-
// Share management (Settings → Shared conversations, the
|
|
1252
|
+
// Share management (Settings → Shared conversations, the ︙ menu). These throw
|
|
1251
1253
|
// with the relay's reason, so the caller can say why an action failed.
|
|
1252
1254
|
export async function listConversationShares() {
|
|
1253
1255
|
return apiFetchOrThrow('/api/shares');
|
|
@@ -1709,13 +1711,14 @@ export async function loadRelayBoard(boardId) {
|
|
|
1709
1711
|
return apiFetch(`/api/relay-board/${encodeURIComponent(id)}`);
|
|
1710
1712
|
}
|
|
1711
1713
|
|
|
1712
|
-
export async function submitRelayBoardAction(boardId, actionId) {
|
|
1714
|
+
export async function submitRelayBoardAction(boardId, actionId, { onErrorResponse } = {}) {
|
|
1713
1715
|
const id = String(boardId || '').trim();
|
|
1714
1716
|
const action = String(actionId || '').trim();
|
|
1715
1717
|
if (!id || !action) return null;
|
|
1716
1718
|
return apiFetch(`/api/relay-board/${encodeURIComponent(id)}/action`, {
|
|
1717
1719
|
method: 'POST',
|
|
1718
1720
|
body: JSON.stringify({ actionId: action }),
|
|
1721
|
+
onErrorResponse,
|
|
1719
1722
|
});
|
|
1720
1723
|
}
|
|
1721
1724
|
|
|
@@ -17,6 +17,7 @@ import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
|
|
|
17
17
|
import { questionAcceptsFreeText } from './question-answer-rule.mjs';
|
|
18
18
|
import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
|
|
19
19
|
import { renderSessionAskCards } from './inbox-ui.js';
|
|
20
|
+
import { isConversationAutoOpenHeld } from './session-history-ui.js';
|
|
20
21
|
|
|
21
22
|
let relayQuestionRenderHash = '';
|
|
22
23
|
let renderedRelayQuestionIds = new Set();
|
|
@@ -124,13 +125,20 @@ export async function loadRelayQuestions(conversationId) {
|
|
|
124
125
|
window.renderConvList?.();
|
|
125
126
|
window.syncComposerButtonState?.();
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
// Nothing open: the latest question's conversation opens. Not from the
|
|
129
|
+
// Inbox or Status, which show nothing open too: they are being read. And
|
|
130
|
+
// in place of the empty screen, not as a step of its own in history. Nor
|
|
131
|
+
// after a link the relay did not know: its notice keeps the screen empty
|
|
132
|
+
// until the reader opens something.
|
|
133
|
+
const readingAView = document.body?.classList?.contains('inbox-view-active')
|
|
134
|
+
|| document.body?.classList?.contains('status-view-active');
|
|
135
|
+
if (!conversationId && pending.length && !readingAView && !isConversationAutoOpenHeld()) {
|
|
128
136
|
const latest = pending
|
|
129
137
|
.slice()
|
|
130
138
|
.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0))[0];
|
|
131
139
|
const targetConversationId = String(latest?.conversationId || '').trim();
|
|
132
140
|
if (targetConversationId && targetConversationId !== currentConvId && conversations[targetConversationId]) {
|
|
133
|
-
await window.openConversation?.(targetConversationId);
|
|
141
|
+
await window.openConversation?.(targetConversationId, { history: 'replace' });
|
|
134
142
|
return;
|
|
135
143
|
}
|
|
136
144
|
}
|
|
@@ -171,10 +179,16 @@ export function updatePendingQuestionBanner() {
|
|
|
171
179
|
banner.dataset.targetQuestionId = String(latest?.id || '').trim();
|
|
172
180
|
const count = pending.length;
|
|
173
181
|
const currentCount = currentConvId ? pending.filter((q) => q?.conversationId === currentConvId).length : 0;
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
const rawTitle = String(conversations[targetConversationId]?.title || targetConversationId || 'conversation');
|
|
183
|
+
const countText = `❓ ${count} open question${count === 1 ? '' : 's'}`;
|
|
184
|
+
const tailText = currentCount > 0 ? '' : ` · latest in ${rawTitle}`;
|
|
185
|
+
// The full text, and the compact chip "❓ N" the header fit switches to
|
|
186
|
+
// (data-compact) where the row is short. Either way the button's name and
|
|
187
|
+
// tooltip are the full text.
|
|
188
|
+
banner.innerHTML = `<span class="pending-question-full"><span class="pending-question-count">${escHtml(countText)}</span>${tailText ? `<span class="pending-question-tail">${escHtml(tailText)}</span>` : ''}</span>`
|
|
189
|
+
+ `<span class="pending-question-compact" aria-hidden="true">❓ ${count}</span>`;
|
|
190
|
+
banner.title = `${countText}${tailText}`;
|
|
191
|
+
banner.setAttribute('aria-label', `${countText}${tailText}`);
|
|
178
192
|
banner.classList.add('visible');
|
|
179
193
|
banner.disabled = false;
|
|
180
194
|
}
|
|
@@ -277,7 +277,7 @@ export function renderAttachmentPreview() {
|
|
|
277
277
|
const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
|
|
278
278
|
const thumb = att.isImage && att.previewUrl
|
|
279
279
|
? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
|
|
280
|
-
: `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
280
|
+
: `<div class="attachment-preview-meta" style="height:calc(88px * var(--ui-scale));display:flex;align-items:center;justify-content:center">📎</div>`;
|
|
281
281
|
const annotateBadge = annotatable
|
|
282
282
|
? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
|
|
283
283
|
: '';
|
|
@@ -9,7 +9,26 @@
|
|
|
9
9
|
|
|
10
10
|
export const BACK_GUARD_FLOOR = 'oarBackGuardFloor';
|
|
11
11
|
export const BACK_GUARD_APP = 'oarApp';
|
|
12
|
-
|
|
12
|
+
// Every entry the app writes carries its place in the stack, so a pop tells
|
|
13
|
+
// back from forward (session-history.mjs, viewer-history.mjs).
|
|
14
|
+
export const HISTORY_POSITION = 'oarPos';
|
|
15
|
+
// The visit's base (the floor, the phone's session list) is laid: a reload
|
|
16
|
+
// on any later entry must not lay it again.
|
|
17
|
+
export const VISIT_LAID = 'oarLaid';
|
|
18
|
+
// The place in the stack of the visit's lowest own entry (the phone's list,
|
|
19
|
+
// else the first session): back from above it stays in the app.
|
|
20
|
+
export const VISIT_BASE = 'oarBase';
|
|
21
|
+
export const FIRST_INTERACTION_EVENTS = Object.freeze(['pointerdown', 'keydown', 'touchstart']);
|
|
22
|
+
|
|
23
|
+
/** The marks every later entry of the visit carries over from the one below. */
|
|
24
|
+
export function inheritedVisitMarks(state) {
|
|
25
|
+
const marks = {};
|
|
26
|
+
if (!state || typeof state !== 'object') return marks;
|
|
27
|
+
if (state[BACK_GUARD_APP]) marks[BACK_GUARD_APP] = true;
|
|
28
|
+
if (state[VISIT_LAID]) marks[VISIT_LAID] = state[VISIT_LAID];
|
|
29
|
+
if (Number.isFinite(state[VISIT_BASE])) marks[VISIT_BASE] = state[VISIT_BASE];
|
|
30
|
+
return marks;
|
|
31
|
+
}
|
|
13
32
|
|
|
14
33
|
/**
|
|
15
34
|
* Lay the floor at the first touch or key, not at start-up: an entry a page
|
|
@@ -19,13 +38,13 @@ const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
|
|
|
19
38
|
* interaction nothing a page does keeps back from leaving; from then on
|
|
20
39
|
* the floor holds.
|
|
21
40
|
*/
|
|
22
|
-
export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null } = {}) {
|
|
41
|
+
export function armBackGuardOnFirstInteraction({ history, enabled = true, target, onFloor = null, entryAgain = null } = {}) {
|
|
23
42
|
if (!enabled || !history || !target) return null;
|
|
24
43
|
const state = { guard: null };
|
|
25
44
|
const arm = () => {
|
|
26
45
|
for (const eventName of FIRST_INTERACTION_EVENTS) target.removeEventListener(eventName, arm, true);
|
|
27
46
|
if (state.guard) return;
|
|
28
|
-
state.guard = installBackGuard({ history, enabled: true, onFloor });
|
|
47
|
+
state.guard = installBackGuard({ history, enabled: true, onFloor, entryAgain });
|
|
29
48
|
target.addEventListener('popstate', () => state.guard.onPopState());
|
|
30
49
|
};
|
|
31
50
|
for (const eventName of FIRST_INTERACTION_EVENTS) target.addEventListener(eventName, arm, true);
|
|
@@ -38,8 +57,12 @@ export function armBackGuardOnFirstInteraction({ history, enabled = true, target
|
|
|
38
57
|
* lets the next back leave unless the user touches the app in between:
|
|
39
58
|
* one swallowed press, then the app closes — the pattern phones use, and
|
|
40
59
|
* the moment to say "press back again to close".
|
|
60
|
+
*
|
|
61
|
+
* `entryAgain` names the entry pushed back above the floor: `{ state, url }`
|
|
62
|
+
* (the session list on a phone, else the session on screen). Without it a
|
|
63
|
+
* bare app entry goes back up.
|
|
41
64
|
*/
|
|
42
|
-
export function installBackGuard({ history, enabled = true, onFloor = null } = {}) {
|
|
65
|
+
export function installBackGuard({ history, enabled = true, onFloor = null, entryAgain = null } = {}) {
|
|
43
66
|
if (!enabled || !history) return null;
|
|
44
67
|
const current = history.state && typeof history.state === 'object' ? history.state : {};
|
|
45
68
|
// After a reload on the app's entry the floor already lies beneath it.
|
|
@@ -51,7 +74,10 @@ export function installBackGuard({ history, enabled = true, onFloor = null } = {
|
|
|
51
74
|
/** Feed every popstate here. */
|
|
52
75
|
onPopState() {
|
|
53
76
|
if (!history.state?.[BACK_GUARD_FLOOR]) return;
|
|
54
|
-
|
|
77
|
+
const again = typeof entryAgain === 'function' ? entryAgain() : null;
|
|
78
|
+
if (again?.state && again.url) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '', again.url);
|
|
79
|
+
else if (again?.state) history.pushState({ ...again.state, [BACK_GUARD_APP]: true }, '');
|
|
80
|
+
else history.pushState({ [BACK_GUARD_APP]: true }, '');
|
|
55
81
|
onFloor?.();
|
|
56
82
|
},
|
|
57
83
|
};
|