@oar-sh/oar 0.9.1 → 0.9.3
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/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -19,19 +19,51 @@ export function hasUploadingAttachments(attachments = []) {
|
|
|
19
19
|
return countUploadingAttachments(attachments) > 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Why a message sent right now queues instead of steering — worker-reported
|
|
23
|
+
// hold reasons mapped to a human title. Stopping a turn lives on the message
|
|
24
|
+
// bubbles, never here, so every state below is a Send/Steer/Queue shape.
|
|
25
|
+
const STEERING_HOLD_TITLES = Object.freeze({
|
|
26
|
+
question: 'Will steer in after you answer the question',
|
|
27
|
+
compaction: 'Will steer in once the conversation finishes compacting',
|
|
28
|
+
adoption: 'Will steer in once the compaction recovery finishes',
|
|
29
|
+
delivery: 'Will steer in once the current delivery lands',
|
|
30
|
+
});
|
|
31
|
+
|
|
22
32
|
export function deriveComposerControlState({
|
|
23
33
|
hasActiveTurn = false,
|
|
24
|
-
cancelRequested = false,
|
|
25
34
|
hasDraft = false,
|
|
26
35
|
sendInFlight = false,
|
|
27
36
|
modelMetadataBlocked = false,
|
|
28
37
|
attachmentsUploading = false,
|
|
38
|
+
// The conversation's session worker delivers a message typed during a live
|
|
39
|
+
// turn INTO that turn (mid-turn steering, advertised on its heartbeat
|
|
40
|
+
// snapshot) rather than queueing it behind — so the control says "Steer",
|
|
41
|
+
// not "Queue". Workers that do not advertise it keep the queue wording.
|
|
42
|
+
steeringSupported = false,
|
|
43
|
+
// The worker reports steering as momentarily held (open question card or
|
|
44
|
+
// plan approval, compaction, post-compaction adoption). A send stays allowed
|
|
45
|
+
// and reads "Queue": the relay holds the message and it steers in once the
|
|
46
|
+
// hold clears.
|
|
47
|
+
steeringHeld = false,
|
|
48
|
+
steeringHoldReason = null,
|
|
29
49
|
} = {}) {
|
|
30
50
|
const active = !!hasActiveTurn;
|
|
31
|
-
const stopping = !!cancelRequested;
|
|
32
51
|
const draft = !!hasDraft;
|
|
33
52
|
const metadataBlocked = !!modelMetadataBlocked;
|
|
34
53
|
const uploading = !!attachmentsUploading;
|
|
54
|
+
const held = active && !!steeringSupported && !!steeringHeld;
|
|
55
|
+
// The label/title/action for a draft typed during a live turn: steer into it
|
|
56
|
+
// where the provider supports that (queued while steering is held), queue
|
|
57
|
+
// behind it otherwise.
|
|
58
|
+
const steerNow = !!steeringSupported && !held;
|
|
59
|
+
const midTurnAction = steerNow ? 'steer' : 'queue';
|
|
60
|
+
const midTurnLabel = steerNow ? 'Steer' : 'Queue';
|
|
61
|
+
const midTurnTitle = held
|
|
62
|
+
? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
|
|
63
|
+
|| 'Will steer in once steering resumes')
|
|
64
|
+
: (steeringSupported
|
|
65
|
+
? 'Steer message into the running turn'
|
|
66
|
+
: 'Queue message behind current turn');
|
|
35
67
|
|
|
36
68
|
if (metadataBlocked && !active) {
|
|
37
69
|
return {
|
|
@@ -42,59 +74,45 @@ export function deriveComposerControlState({
|
|
|
42
74
|
};
|
|
43
75
|
}
|
|
44
76
|
|
|
45
|
-
// Uploads are eager, so the blocking window is short.
|
|
46
|
-
//
|
|
47
|
-
// flipping meaning mid-upload while a turn is running.
|
|
77
|
+
// Uploads are eager, so the blocking window is short. The control keeps its
|
|
78
|
+
// Send/Steer/Queue meaning while it waits.
|
|
48
79
|
if (uploading) {
|
|
49
|
-
const
|
|
80
|
+
const midTurn = active && draft;
|
|
50
81
|
return {
|
|
51
|
-
action:
|
|
52
|
-
label:
|
|
82
|
+
action: midTurn ? midTurnAction : 'send',
|
|
83
|
+
label: midTurn ? midTurnLabel : 'Send',
|
|
53
84
|
title: 'Waiting for attachments to finish uploading',
|
|
54
85
|
disabled: true,
|
|
55
86
|
};
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
if (sendInFlight) {
|
|
59
|
-
if (active && draft) {
|
|
60
|
-
return {
|
|
61
|
-
action: 'queue',
|
|
62
|
-
label: 'Queue',
|
|
63
|
-
title: 'Queue message behind current turn',
|
|
64
|
-
disabled: true,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
if (active) {
|
|
68
|
-
return {
|
|
69
|
-
action: 'stop',
|
|
70
|
-
label: stopping ? 'Stopping…' : 'Stop',
|
|
71
|
-
title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
|
|
72
|
-
disabled: true,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
90
|
return {
|
|
76
|
-
action: 'send',
|
|
77
|
-
label: 'Send',
|
|
78
|
-
title: 'Send message',
|
|
91
|
+
action: active && draft ? midTurnAction : 'send',
|
|
92
|
+
label: active && draft ? midTurnLabel : 'Send',
|
|
93
|
+
title: active && draft ? midTurnTitle : 'Send message',
|
|
79
94
|
disabled: true,
|
|
80
95
|
};
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
if (active && draft) {
|
|
84
99
|
return {
|
|
85
|
-
action:
|
|
86
|
-
label:
|
|
87
|
-
title:
|
|
100
|
+
action: midTurnAction,
|
|
101
|
+
label: midTurnLabel,
|
|
102
|
+
title: midTurnTitle,
|
|
88
103
|
disabled: false,
|
|
89
104
|
};
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
if (active) {
|
|
108
|
+
// Empty composer during a live turn: nothing to send, nothing to steer.
|
|
109
|
+
// The label stays "Send" (it only reads "Steer" with text present) and
|
|
110
|
+
// re-enables the moment the user types or the turn ends.
|
|
93
111
|
return {
|
|
94
|
-
action: '
|
|
95
|
-
label:
|
|
96
|
-
title:
|
|
97
|
-
disabled:
|
|
112
|
+
action: 'send',
|
|
113
|
+
label: 'Send',
|
|
114
|
+
title: 'Type a message to send',
|
|
115
|
+
disabled: true,
|
|
98
116
|
};
|
|
99
117
|
}
|
|
100
118
|
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
// Mirrors shared/model-id.mjs, which the browser cannot import (only
|
|
2
2
|
// server/public is served).
|
|
3
3
|
const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1000000;
|
|
4
|
-
const UNKNOWN_WINDOW_LABEL = '—';
|
|
4
|
+
export const UNKNOWN_WINDOW_LABEL = '—';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Shared by the context chip and both model pickers so a window reads the same
|
|
7
|
+
// everywhere: "400K", "1M", "1.05M" (the chip is 84px wide; "1050K" overflows).
|
|
8
|
+
export function tokenLabel(tokens) {
|
|
7
9
|
const limit = Number(tokens);
|
|
8
|
-
|
|
10
|
+
if (!Number.isFinite(limit) || limit <= 0) return UNKNOWN_WINDOW_LABEL;
|
|
11
|
+
if (limit >= 1000000) {
|
|
12
|
+
return `${String((limit / 1000000).toFixed(2)).replace(/\.?0+$/, '')}M`;
|
|
13
|
+
}
|
|
14
|
+
return `${Math.round(limit / 1000)}K`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// This chip is a context-TIER selector, so the default option must show the
|
|
18
|
+
// default tier's own limit: the relay now caps defaultContextLimitTokens at
|
|
19
|
+
// the runtime's real window (haiku 144K, gpt-5.4-mini 400K), while a model
|
|
20
|
+
// with a long-context tier keeps a smaller default (gpt-5.6-terra: 400K
|
|
21
|
+
// default, 1.05M long). Labelling the default with the full window would
|
|
22
|
+
// render both of terra's tiers as "1.05M". The real window is only the
|
|
23
|
+
// fallback for metadata that carries no derived default at all.
|
|
24
|
+
function defaultWindowTokens(meta = {}) {
|
|
25
|
+
const derived = Number(meta.defaultContextLimitTokens);
|
|
26
|
+
if (Number.isFinite(derived) && derived > 0) return derived;
|
|
27
|
+
const real = Number(meta.contextWindowTokens);
|
|
28
|
+
return Number.isFinite(real) && real > 0 ? real : null;
|
|
9
29
|
}
|
|
10
30
|
|
|
11
31
|
function claudeTiersForModel(modelId, claudeTiers) {
|
|
@@ -33,7 +53,7 @@ export function buildContextTierOptions({
|
|
|
33
53
|
claudeTiers = null,
|
|
34
54
|
} = {}) {
|
|
35
55
|
const meta = metadata && typeof metadata === 'object' ? metadata : {};
|
|
36
|
-
const defaultLabel = tokenLabel(meta
|
|
56
|
+
const defaultLabel = tokenLabel(defaultWindowTokens(meta));
|
|
37
57
|
const provider = String(providerType || '').trim().toLowerCase();
|
|
38
58
|
const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
|
|
39
59
|
if (claudeTierList) {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Per-conversation record of the server draft this client's composer last
|
|
2
|
+
// agreed with. The composer text is local state that changes on every
|
|
3
|
+
// keystroke; this record changes only when the server acknowledges a save or
|
|
4
|
+
// the composer adopts a remote draft. Its updatedAt is the base version every
|
|
5
|
+
// draft save is checked against, so it must never be touched by a local edit.
|
|
6
|
+
|
|
7
|
+
import { serializeDraftAttachments } from './composer-attachment-cache.mjs';
|
|
8
|
+
import { normalizeDraftTimestampMs } from './conversation-draft-timestamp-utils.mjs';
|
|
9
|
+
|
|
10
|
+
// Sent with every draft save. It is what lets the server conflict-check an
|
|
11
|
+
// explicit null base: clients from before this protocol also send null (JSON
|
|
12
|
+
// keeps it), and for them null has to keep meaning "unconditional".
|
|
13
|
+
export const DRAFT_SYNC_PROTOCOL_VERSION = 2;
|
|
14
|
+
|
|
15
|
+
// Must equal the server's MAX_CONVERSATION_DRAFT_LENGTH (a test pins it). The
|
|
16
|
+
// server truncates longer drafts, so the client saves and compares only this
|
|
17
|
+
// prefix; otherwise a long composer never matches its acknowledged draft and
|
|
18
|
+
// is re-saved on every refresh. The composer itself (and the message) is not
|
|
19
|
+
// limited — only what survives as a draft.
|
|
20
|
+
export const MAX_DRAFT_TEXT_LENGTH = 20_000;
|
|
21
|
+
|
|
22
|
+
// Cuts at a code-point boundary, exactly like the server: a cut through a
|
|
23
|
+
// surrogate pair would store a lone surrogate, which SQLite hands back as
|
|
24
|
+
// U+FFFD — a different text at the same version.
|
|
25
|
+
export function draftTextForSync(text) {
|
|
26
|
+
const value = String(text || '');
|
|
27
|
+
if (value.length <= MAX_DRAFT_TEXT_LENGTH) return value;
|
|
28
|
+
const cut = value.slice(0, MAX_DRAFT_TEXT_LENGTH);
|
|
29
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
30
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const syncedDraftsByConversation = new Map();
|
|
34
|
+
|
|
35
|
+
function conversationKey(conversationId) {
|
|
36
|
+
return String(conversationId || '').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Order-sensitive fingerprint of a draft attachment list; null when absent. */
|
|
40
|
+
export function draftAttachmentsKey(attachments) {
|
|
41
|
+
if (attachments === undefined || attachments === null) return null;
|
|
42
|
+
return serializeDraftAttachments(Array.isArray(attachments) ? attachments : [], { max: Number.MAX_SAFE_INTEGER })
|
|
43
|
+
.map((row) => row.sha256)
|
|
44
|
+
.join(',');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getSyncedDraft(conversationId) {
|
|
48
|
+
return syncedDraftsByConversation.get(conversationKey(conversationId)) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function recordSyncedDraft(conversationId, {
|
|
52
|
+
text = '',
|
|
53
|
+
attachments = undefined,
|
|
54
|
+
updatedAt = null,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const id = conversationKey(conversationId);
|
|
57
|
+
if (!id) return null;
|
|
58
|
+
const previous = syncedDraftsByConversation.get(id) || null;
|
|
59
|
+
const incomingKey = draftAttachmentsKey(attachments);
|
|
60
|
+
const record = {
|
|
61
|
+
text: String(text || ''),
|
|
62
|
+
attachmentsKey: incomingKey === null ? (previous?.attachmentsKey ?? null) : incomingKey,
|
|
63
|
+
updatedAt: updatedAt || null,
|
|
64
|
+
};
|
|
65
|
+
syncedDraftsByConversation.set(id, record);
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function forgetSyncedDraft(conversationId) {
|
|
70
|
+
syncedDraftsByConversation.delete(conversationKey(conversationId));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A save is a no-op when the server already holds exactly this text (and,
|
|
75
|
+
* for saves that carry attachments, exactly these attachments) — but only
|
|
76
|
+
* while no newer server version is known. If one is (a remote draft deferred
|
|
77
|
+
* while the user typed), the save must still go out so its version check
|
|
78
|
+
* surfaces that draft instead of silently keeping a stale base.
|
|
79
|
+
*/
|
|
80
|
+
export function isDraftSaveNoop({ synced = null, text = '', attachmentsKey = null, knownUpdatedAt = null } = {}) {
|
|
81
|
+
if (!synced) return false;
|
|
82
|
+
if (normalizeDraftTimestampMs(knownUpdatedAt) > normalizeDraftTimestampMs(synced.updatedAt)) return false;
|
|
83
|
+
if (String(text || '') !== synced.text) return false;
|
|
84
|
+
if (attachmentsKey === null) return true;
|
|
85
|
+
return synced.attachmentsKey !== null && attachmentsKey === synced.attachmentsKey;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decides a 409 draft conflict.
|
|
90
|
+
* - converged: the server already holds what we wanted to save.
|
|
91
|
+
* - adopt: the local text is unchanged since the last sync, so the remote
|
|
92
|
+
* draft simply wins.
|
|
93
|
+
* - keep-local: both sides edited; the local (newest) keystroke wins and the
|
|
94
|
+
* remote text becomes the one offered for restore.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveDraftConflict({
|
|
97
|
+
localText = '',
|
|
98
|
+
localAttachmentsKey = null,
|
|
99
|
+
baseText = null,
|
|
100
|
+
baseAttachmentsKey = null,
|
|
101
|
+
serverText = '',
|
|
102
|
+
serverAttachmentsKey = null,
|
|
103
|
+
} = {}) {
|
|
104
|
+
const local = String(localText || '');
|
|
105
|
+
const attachmentsMatch = (other) => localAttachmentsKey === null || localAttachmentsKey === other;
|
|
106
|
+
if (local === String(serverText || '') && attachmentsMatch(serverAttachmentsKey)) return 'converged';
|
|
107
|
+
if (baseText !== null && local === baseText && attachmentsMatch(baseAttachmentsKey)) return 'adopt';
|
|
108
|
+
return 'keep-local';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether an incoming remote draft may replace the composer of the
|
|
113
|
+
* conversation on screen. Only an unmodified composer (text and attachments
|
|
114
|
+
* still equal to the last synced draft) adopts it, focused or not. A modified
|
|
115
|
+
* one never does — not even for an equal or newer version: its edit is unsaved
|
|
116
|
+
* (a failed flush leaves exactly this state), and the caller re-saves it so the
|
|
117
|
+
* version check decides. An upload still in flight counts as a modification.
|
|
118
|
+
* With nothing synced yet there is no saved edit to protect.
|
|
119
|
+
* Attachment keys are null when unknown and then do not take part.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldApplyIncomingDraftToComposer({
|
|
122
|
+
inputText = '',
|
|
123
|
+
incomingText = '',
|
|
124
|
+
syncedText = null,
|
|
125
|
+
inputAttachmentsKey = null,
|
|
126
|
+
incomingAttachmentsKey = null,
|
|
127
|
+
syncedAttachmentsKey = null,
|
|
128
|
+
attachmentsUploading = false,
|
|
129
|
+
} = {}) {
|
|
130
|
+
if (attachmentsUploading) return false;
|
|
131
|
+
const current = draftTextForSync(inputText);
|
|
132
|
+
const sameAttachments = (other) => inputAttachmentsKey === null || other === null || inputAttachmentsKey === other;
|
|
133
|
+
if (current === String(incomingText || '') && sameAttachments(incomingAttachmentsKey)) return true;
|
|
134
|
+
if (syncedText === null) return true;
|
|
135
|
+
return current === syncedText && sameAttachments(syncedAttachmentsKey);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* PATCH body for the pagehide Background Sync copy of a draft flush, or null
|
|
140
|
+
* when the composer holds nothing unsaved. Compared against the synced draft,
|
|
141
|
+
* not the local text (which every keystroke updates).
|
|
142
|
+
*/
|
|
143
|
+
export function draftFlushRequestBody({
|
|
144
|
+
inputText = '',
|
|
145
|
+
synced = null,
|
|
146
|
+
fallbackText = '',
|
|
147
|
+
fallbackUpdatedAt = null,
|
|
148
|
+
clientId = null,
|
|
149
|
+
afterPendingSend = false,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const draftText = draftTextForSync(inputText);
|
|
152
|
+
const serverText = synced ? synced.text : draftTextForSync(fallbackText);
|
|
153
|
+
if (draftText === serverText) return null;
|
|
154
|
+
if (afterPendingSend) {
|
|
155
|
+
// An emptied composer after a send is what the send leaves anyway.
|
|
156
|
+
if (!draftText.trim()) return null;
|
|
157
|
+
// The send will move the server's version past this base. acceptOwnSend
|
|
158
|
+
// lets the replay through only while that move is this client's own (the
|
|
159
|
+
// send transaction records the sender); anyone else's newer draft still
|
|
160
|
+
// wins with a 409.
|
|
161
|
+
return {
|
|
162
|
+
draftText,
|
|
163
|
+
clientId,
|
|
164
|
+
draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
|
|
165
|
+
baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
|
|
166
|
+
acceptOwnSend: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
draftText,
|
|
171
|
+
clientId,
|
|
172
|
+
draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
|
|
173
|
+
baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A queued draft flush replayed later than this (Firefox/Safari replay the
|
|
178
|
+
// outbox only on the next page load, possibly hours on) would write text
|
|
179
|
+
// nobody is looking at over whatever the draft became meanwhile. sw.js
|
|
180
|
+
// mirrors this value; a test pins them together.
|
|
181
|
+
export const DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
|
|
182
|
+
|
|
183
|
+
export function isStaleDraftFlushEntry(entry, now = Date.now()) {
|
|
184
|
+
if (String(entry?.kind || '') !== 'draft-flush') return false;
|
|
185
|
+
const createdAt = Number(entry?.createdAt || 0);
|
|
186
|
+
return !Number.isFinite(createdAt) || createdAt <= 0 || now - createdAt > DRAFT_FLUSH_MAX_AGE_MS;
|
|
187
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Title filtering for the conversation sidebar. Kept DOM-free so it can be unit
|
|
2
|
+
// tested; journal-view.js touches window/document at module scope and cannot be
|
|
3
|
+
// imported from node:test.
|
|
4
|
+
|
|
5
|
+
// NFC first so a decomposed "é" (e + U+0301, as some IMEs and pasted text
|
|
6
|
+
// produce) matches a precomposed one. Case folding is toLocaleLowerCase only, so
|
|
7
|
+
// multi-char folds are a known limit: "ß" does not match "SS".
|
|
8
|
+
function foldForMatch(text) {
|
|
9
|
+
return String(text ?? '').normalize('NFC').toLocaleLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeConversationFilter(text) {
|
|
13
|
+
return foldForMatch(text).trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function conversationMatchesFilter(conversation, normalizedFilter) {
|
|
17
|
+
if (!normalizedFilter) return true;
|
|
18
|
+
return foldForMatch(conversation?.title).includes(normalizedFilter);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function filterConversations(conversations, filterText) {
|
|
22
|
+
const list = Array.isArray(conversations) ? conversations : [];
|
|
23
|
+
const normalized = normalizeConversationFilter(filterText);
|
|
24
|
+
if (!normalized) return list;
|
|
25
|
+
return list.filter((conversation) => conversationMatchesFilter(conversation, normalized));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeFilterMatchCount(count, { loadedOnly = false } = {}) {
|
|
29
|
+
const n = Math.max(0, Number(count) || 0);
|
|
30
|
+
const noun = loadedOnly ? 'loaded conversation' : 'conversation';
|
|
31
|
+
if (n === 0) return `No ${noun}s match`;
|
|
32
|
+
return n === 1 ? `1 ${noun} matches` : `${n} ${noun}s match`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cursorKey(state) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(state?.nextCursor ?? null);
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// While a filter is active the sidebar has to search conversations that only
|
|
44
|
+
// exist on unloaded pages, so pull every remaining page through the loader.
|
|
45
|
+
// shouldContinue() is checked before each page so clearing the filter (or
|
|
46
|
+
// switching it) aborts the drain. loadMore() resolving falsy is ambiguous:
|
|
47
|
+
// - the loader is busy (a boundary-check load/prefetch is in flight): wait it
|
|
48
|
+
// out without spending idle retries, so the drain does not give up while the
|
|
49
|
+
// list is still loading. busyRetries caps a genuinely stuck fetch.
|
|
50
|
+
// - a reset bumped the loader version mid-fetch, or the fetch failed: retry,
|
|
51
|
+
// but stop after maxIdleRetries consecutive no-progress attempts so a failing
|
|
52
|
+
// endpoint is not hammered forever.
|
|
53
|
+
// An advanced cursor counts as progress even when the page was empty.
|
|
54
|
+
export async function drainRemainingPages(loader, shouldContinue = () => true, {
|
|
55
|
+
retryDelayMs = 200,
|
|
56
|
+
maxIdleRetries = 10,
|
|
57
|
+
maxBusyRetries = 300,
|
|
58
|
+
delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (!loader || typeof loader.loadMore !== 'function' || typeof loader.getState !== 'function') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
let loadedAnything = false;
|
|
64
|
+
let idleRetries = 0;
|
|
65
|
+
let busyRetries = 0;
|
|
66
|
+
while (loader.getState().hasMore && shouldContinue()) {
|
|
67
|
+
const cursorBefore = cursorKey(loader.getState());
|
|
68
|
+
const loaded = await loader.loadMore();
|
|
69
|
+
const state = loader.getState();
|
|
70
|
+
if (loaded) loadedAnything = true;
|
|
71
|
+
if (loaded || cursorKey(state) !== cursorBefore) {
|
|
72
|
+
idleRetries = 0;
|
|
73
|
+
busyRetries = 0;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (state.isLoading || state.isPrefetching) {
|
|
77
|
+
busyRetries += 1;
|
|
78
|
+
if (busyRetries > maxBusyRetries) break;
|
|
79
|
+
} else {
|
|
80
|
+
idleRetries += 1;
|
|
81
|
+
if (idleRetries > maxIdleRetries) break;
|
|
82
|
+
}
|
|
83
|
+
await delay(retryDelayMs);
|
|
84
|
+
}
|
|
85
|
+
return loadedAnything;
|
|
86
|
+
}
|
|
@@ -69,6 +69,49 @@ export function resolveConversationComposerSelection({
|
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Every effort rung any provider speaks, lowest first. 'ultracode' sits on top
|
|
73
|
+
// but is a Claude mode rather than "more thinking", so it is never a clamp
|
|
74
|
+
// target and a remembered ultracode does not degrade to xhigh (it falls
|
|
75
|
+
// through to the next candidate — the existing "drops cleanly" rule).
|
|
76
|
+
export const REASONING_EFFORT_LADDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
|
|
77
|
+
|
|
78
|
+
function normalizeEffortList(efforts = []) {
|
|
79
|
+
return (Array.isArray(efforts) ? efforts : [])
|
|
80
|
+
.map((effort) => String(effort || '').trim().toLowerCase())
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clamp rule, per candidate in priority order:
|
|
86
|
+
* 1. the candidate itself when the model supports it;
|
|
87
|
+
* 2. else the highest supported rung BELOW it, ignoring 'none'
|
|
88
|
+
* (gpt-5.6-terra@max → gpt-5.4-mini lands on xhigh);
|
|
89
|
+
* 3. else the lowest supported rung ABOVE it, ignoring 'none'
|
|
90
|
+
* (a remembered 'minimal' on a model whose floor is 'low' → low);
|
|
91
|
+
* 4. else the next candidate.
|
|
92
|
+
* With no candidate usable: the lowest non-'none' rung the model offers, or
|
|
93
|
+
* 'none' when that is all there is. Effort names off the ladder (image
|
|
94
|
+
* quality values, unknown providers) only ever match exactly.
|
|
95
|
+
*/
|
|
96
|
+
export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
|
|
97
|
+
const options = normalizeEffortList(supportedEfforts);
|
|
98
|
+
if (!options.length) return '';
|
|
99
|
+
const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
|
|
100
|
+
const ranked = options
|
|
101
|
+
.filter((option) => option !== 'none' && rungOf(option) >= 0)
|
|
102
|
+
.sort((left, right) => rungOf(left) - rungOf(right));
|
|
103
|
+
for (const candidate of normalizeEffortList(candidates)) {
|
|
104
|
+
if (options.includes(candidate)) return candidate;
|
|
105
|
+
const rung = rungOf(candidate);
|
|
106
|
+
if (rung < 0 || candidate === 'ultracode') continue;
|
|
107
|
+
const below = ranked.filter((option) => rungOf(option) < rung);
|
|
108
|
+
if (below.length) return below[below.length - 1];
|
|
109
|
+
const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
|
|
110
|
+
if (above) return above;
|
|
111
|
+
}
|
|
112
|
+
return options.find((option) => option !== 'none') || options[0];
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
// Priority: an explicit request (conversation preference, or the effort being
|
|
73
116
|
// carried across a user model change) beats what the previous conversation left
|
|
74
117
|
// in the DOM. Reversing those two is what let a New Chat "high" become "low".
|
|
@@ -78,14 +121,8 @@ export function resolveComposerReasoningEffort({
|
|
|
78
121
|
currentEffort = '',
|
|
79
122
|
supportedEfforts = [],
|
|
80
123
|
} = {}) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const candidates = [preferredEffort, storedEffort, currentEffort]
|
|
86
|
-
.map((effort) => String(effort || '').trim().toLowerCase())
|
|
87
|
-
.filter(Boolean);
|
|
88
|
-
const match = candidates.find((candidate) => options.includes(candidate));
|
|
89
|
-
if (match) return match;
|
|
90
|
-
return options.find((option) => option !== 'none') || options[0];
|
|
124
|
+
return nearestSupportedReasoningEffort(
|
|
125
|
+
[preferredEffort, storedEffort, currentEffort],
|
|
126
|
+
supportedEfforts,
|
|
127
|
+
);
|
|
91
128
|
}
|
|
@@ -35,6 +35,14 @@ export function conversationProviderIndicatorLabel(conversation = null) {
|
|
|
35
35
|
return '';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A question card names the runtime that asks, the way the list pill names it.
|
|
39
|
+
// Unresolved reads plain "Question": every card used to say "Copilot question",
|
|
40
|
+
// Claude's and Cursor's included.
|
|
41
|
+
export function questionCardHeadLabel(conversation = null) {
|
|
42
|
+
const provider = conversationProviderIndicatorLabel(conversation);
|
|
43
|
+
return provider ? `${provider} question` : 'Question';
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
const SESSION_LOCK_LABELS = {
|
|
39
47
|
github: 'GitHub Copilot',
|
|
40
48
|
openai: 'OpenAI',
|