@oar-sh/oar 0.9.5 → 0.9.7
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 +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// Pure helpers for the Claude Cloud provider (`claude-cloud`): the New Chat
|
|
2
|
+
// repository/branch fields and their warning lines, the conversation's cloud
|
|
3
|
+
// line, the composer's model list and the image-only attachment rule.
|
|
4
|
+
//
|
|
5
|
+
// DOM-free on purpose, like remote-relay-origin-view.mjs: the callers
|
|
6
|
+
// (journal-view.js, claude-cloud-conversation-ui.js, attachments-view.js,
|
|
7
|
+
// bootstrap.js) own the elements, this module owns the decisions.
|
|
8
|
+
//
|
|
9
|
+
// The repository, branch and compare-link rules mirror
|
|
10
|
+
// shared/claude-cloud/repo-url.mjs, which the browser cannot import (only
|
|
11
|
+
// server/public is served); claude-cloud-ui.test.mjs holds the two together.
|
|
12
|
+
// They exist to say what is wrong before a round trip; the server validates
|
|
13
|
+
// again and its answer is the one that counts.
|
|
14
|
+
|
|
15
|
+
export const CLAUDE_CLOUD_PROVIDER = 'claude-cloud';
|
|
16
|
+
export const CLAUDE_CLOUD_LABEL = 'Claude Cloud';
|
|
17
|
+
export const CLAUDE_CLOUD_MARKER = '☁';
|
|
18
|
+
export const CLAUDE_CODE_WEB_URL = 'https://claude.ai/code';
|
|
19
|
+
export const CLAUDE_CLOUD_SETTINGS_PATH = 'Settings → Providers → Claude Cloud';
|
|
20
|
+
|
|
21
|
+
function oneLine(value) {
|
|
22
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeHtml(value) {
|
|
26
|
+
return String(value ?? '')
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, ''');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isClaudeCloudProviderType(providerType = '') {
|
|
35
|
+
return String(providerType || '').trim().toLowerCase() === CLAUDE_CLOUD_PROVIDER;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isClaudeCloudConversation(conversation = null) {
|
|
39
|
+
return isClaudeCloudProviderType(
|
|
40
|
+
conversation?.runtimeProviderType ?? conversation?.runtime_provider_type ?? '',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Repository and branch inputs ──
|
|
45
|
+
|
|
46
|
+
const GITHUB_HOSTS = new Set(['github.com', 'www.github.com']);
|
|
47
|
+
const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
48
|
+
const REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
|
|
49
|
+
const MAX_BRANCH_LENGTH = 255;
|
|
50
|
+
|
|
51
|
+
function ownerAndRepoFromPath(pathText) {
|
|
52
|
+
const parts = String(pathText || '').split('/').filter(Boolean);
|
|
53
|
+
// Exactly owner/repo: a pasted page URL (…/tree/main) names something else.
|
|
54
|
+
if (parts.length !== 2) return null;
|
|
55
|
+
const owner = parts[0];
|
|
56
|
+
const repo = parts[1].replace(/\.git$/i, '');
|
|
57
|
+
if (!OWNER_PATTERN.test(owner) || owner.endsWith('-')) return null;
|
|
58
|
+
if (!REPO_PATTERN.test(repo) || repo === '.' || repo === '..') return null;
|
|
59
|
+
return { owner, repo };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A typed or detected repository as `{ repoUrl, owner, repo, slug }`, or null
|
|
64
|
+
* when it does not name a GitHub repository. `repoUrl` is always the https
|
|
65
|
+
* form, which is what the bootstrap request carries.
|
|
66
|
+
*
|
|
67
|
+
* Same forms as the server's normalizeGitHubRepoUrl, plus one it does not
|
|
68
|
+
* take: a bare `owner/repo`, because a full URL is a lot to type on a phone.
|
|
69
|
+
* That is safe only because the https form is what gets sent.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeCloudRepoInput(input = '') {
|
|
72
|
+
const text = String(input ?? '').trim();
|
|
73
|
+
if (!text || /\s/.test(text)) return null;
|
|
74
|
+
|
|
75
|
+
let parsed = null;
|
|
76
|
+
if (/^[^/:@]+\/[^/:@]+\/?$/.test(text)) {
|
|
77
|
+
parsed = ownerAndRepoFromPath(text);
|
|
78
|
+
} else {
|
|
79
|
+
let host = '';
|
|
80
|
+
let pathText = '';
|
|
81
|
+
// scp-like: [user@]host:owner/repo(.git). A scheme is followed by `//`.
|
|
82
|
+
const scp = text.match(/^(?:[A-Za-z0-9._-]+@)?([A-Za-z0-9.-]+):(?!\/\/)(.+)$/);
|
|
83
|
+
if (scp) {
|
|
84
|
+
host = scp[1];
|
|
85
|
+
pathText = scp[2];
|
|
86
|
+
} else {
|
|
87
|
+
const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}`;
|
|
88
|
+
let url;
|
|
89
|
+
try {
|
|
90
|
+
url = new URL(withScheme);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!['https:', 'http:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol)) return null;
|
|
95
|
+
host = url.hostname;
|
|
96
|
+
pathText = url.pathname;
|
|
97
|
+
}
|
|
98
|
+
if (!GITHUB_HOSTS.has(host.toLowerCase())) return null;
|
|
99
|
+
parsed = ownerAndRepoFromPath(pathText);
|
|
100
|
+
}
|
|
101
|
+
if (!parsed) return null;
|
|
102
|
+
const slug = `${parsed.owner}/${parsed.repo}`;
|
|
103
|
+
return { repoUrl: `https://github.com/${slug}`, owner: parsed.owner, repo: parsed.repo, slug };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The rules of `git check-ref-format --branch`. Nothing is trimmed: a name
|
|
107
|
+
// with a space around it is not a branch name.
|
|
108
|
+
export function isValidCloudBranchName(value = '') {
|
|
109
|
+
if (typeof value !== 'string') return false;
|
|
110
|
+
if (!value || value.length > MAX_BRANCH_LENGTH) return false;
|
|
111
|
+
if (value === '@' || value.startsWith('-')) return false;
|
|
112
|
+
// Control characters, space, and the characters git reserves for revisions.
|
|
113
|
+
if (/[\u0000- \u007f~^:?*[\\]/.test(value)) return false;
|
|
114
|
+
if (value.includes('..') || value.includes('@{') || value.includes('//')) return false;
|
|
115
|
+
if (value.startsWith('/') || value.endsWith('/') || value.endsWith('.')) return false;
|
|
116
|
+
return value.split('/').every((part) => !part.startsWith('.') && !part.endsWith('.lock'));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the Start button does with the two fields: either the `cloudSource` to
|
|
121
|
+
* send, or one message per field that needs fixing. An empty branch is valid
|
|
122
|
+
* (the cloud clones the repository's default branch).
|
|
123
|
+
*/
|
|
124
|
+
export function validateCloudSourceInputs({ repo = '', branch = '' } = {}) {
|
|
125
|
+
const repoText = String(repo ?? '').trim();
|
|
126
|
+
const branchText = String(branch ?? '').trim();
|
|
127
|
+
const errors = {};
|
|
128
|
+
const normalized = normalizeCloudRepoInput(repoText);
|
|
129
|
+
if (!repoText) {
|
|
130
|
+
errors.repo = 'Enter a GitHub repository.';
|
|
131
|
+
} else if (!normalized) {
|
|
132
|
+
errors.repo = 'Not a GitHub repository. Use owner/repo or a github.com URL.';
|
|
133
|
+
}
|
|
134
|
+
if (branchText && !isValidCloudBranchName(branchText)) {
|
|
135
|
+
errors.branch = 'Not a valid branch name.';
|
|
136
|
+
}
|
|
137
|
+
if (errors.repo || errors.branch) {
|
|
138
|
+
return { ok: false, cloudSource: null, slug: normalized?.slug || '', errors };
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
cloudSource: { repoUrl: normalized.repoUrl, ...(branchText ? { branch: branchText } : {}) },
|
|
143
|
+
slug: normalized.slug,
|
|
144
|
+
errors,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* What the two fields should hold after a folder lookup. A folder with a
|
|
150
|
+
* GitHub remote always fills them (picking a folder is an explicit choice). A
|
|
151
|
+
* folder without one clears only what an earlier lookup put there, so a
|
|
152
|
+
* repository typed by hand survives browsing through folders.
|
|
153
|
+
*/
|
|
154
|
+
export function resolveCloudSourceAutoFill({ current = {}, lastAutoFill = null, remote = null } = {}) {
|
|
155
|
+
const currentRepo = String(current?.repo ?? '');
|
|
156
|
+
const currentBranch = String(current?.branch ?? '');
|
|
157
|
+
const detectedRepo = String(remote?.repoUrl || '').trim();
|
|
158
|
+
if (detectedRepo) {
|
|
159
|
+
const detected = { repo: detectedRepo, branch: String(remote?.branch || '').trim() };
|
|
160
|
+
return { ...detected, autoFill: detected };
|
|
161
|
+
}
|
|
162
|
+
const untouched = !!lastAutoFill
|
|
163
|
+
&& currentRepo.trim() === String(lastAutoFill.repo || '')
|
|
164
|
+
&& currentBranch.trim() === String(lastAutoFill.branch || '');
|
|
165
|
+
if (untouched) return { repo: '', branch: '', autoFill: null };
|
|
166
|
+
return { repo: currentRepo, branch: currentBranch, autoFill: lastAutoFill || null };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The line under the New Chat folder picker while Claude Cloud is selected.
|
|
171
|
+
* The folder is only a way to fill the two fields: nothing runs in it.
|
|
172
|
+
*/
|
|
173
|
+
export function cloudFolderStatusText(folderPath = '') {
|
|
174
|
+
const path = String(folderPath || '').trim();
|
|
175
|
+
return path
|
|
176
|
+
? `Repository and branch are read from ${path}. The chat itself runs in the cloud.`
|
|
177
|
+
: 'The chat runs in the cloud. Pick a folder to fill in its repository and branch, or type them below.';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function commitCount(value) {
|
|
181
|
+
const count = Number(value);
|
|
182
|
+
return Number.isFinite(count) && count > 0 ? Math.trunc(count) : 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The warning lines under the Repository and Branch fields.
|
|
187
|
+
*
|
|
188
|
+
* `remote` is the GET /api/git/remote payload for the selected folder (null
|
|
189
|
+
* when no folder was looked up), `lookupFailed` a request that did not come
|
|
190
|
+
* back. The folder's unpushed/dirty state only matters while the fields still
|
|
191
|
+
* name that folder's repository and branch: once the user types another one,
|
|
192
|
+
* the cloud clone has nothing to do with the local checkout. `repoAccess` is
|
|
193
|
+
* what cloudRepoAccess said about the repository in the field.
|
|
194
|
+
*/
|
|
195
|
+
export function buildCloudSourceWarnings({
|
|
196
|
+
remote = null,
|
|
197
|
+
lookupFailed = false,
|
|
198
|
+
repo = '',
|
|
199
|
+
branch = '',
|
|
200
|
+
environmentId = '',
|
|
201
|
+
checkEnvironment = true,
|
|
202
|
+
repoAccess = 'unknown',
|
|
203
|
+
} = {}) {
|
|
204
|
+
const warnings = [];
|
|
205
|
+
if (lookupFailed || (remote && remote.ok === false)) {
|
|
206
|
+
warnings.push({
|
|
207
|
+
kind: 'lookup-failed',
|
|
208
|
+
text: 'Could not read git details for this folder — enter the repository by hand.',
|
|
209
|
+
});
|
|
210
|
+
} else if (remote) {
|
|
211
|
+
const folderRepo = normalizeCloudRepoInput(remote.repoUrl || remote.slug || '');
|
|
212
|
+
if (remote.hasGit === false) {
|
|
213
|
+
warnings.push({
|
|
214
|
+
kind: 'no-git',
|
|
215
|
+
text: 'This folder is not a git repository — enter the repository by hand.',
|
|
216
|
+
});
|
|
217
|
+
} else if (!folderRepo) {
|
|
218
|
+
warnings.push({
|
|
219
|
+
kind: 'no-remote',
|
|
220
|
+
text: String(remote.remoteUrl || '').trim()
|
|
221
|
+
? 'The remote of this folder is not on GitHub — cloud sessions need a GitHub repository.'
|
|
222
|
+
: 'No GitHub remote in this folder — enter the repository by hand.',
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
const typedRepo = normalizeCloudRepoInput(repo);
|
|
226
|
+
const sameRepo = !!typedRepo && typedRepo.slug.toLowerCase() === folderRepo.slug.toLowerCase();
|
|
227
|
+
const folderBranch = String(remote.branch || '').trim();
|
|
228
|
+
const sameBranch = sameRepo && !!folderBranch && String(branch ?? '').trim() === folderBranch;
|
|
229
|
+
const ahead = commitCount(remote.ahead);
|
|
230
|
+
if (sameBranch && !String(remote.upstream || '').trim()) {
|
|
231
|
+
warnings.push({
|
|
232
|
+
kind: 'no-upstream',
|
|
233
|
+
text: `Branch ${folderBranch} has no upstream — push it first, the cloud clone only sees GitHub.`,
|
|
234
|
+
});
|
|
235
|
+
} else if (sameBranch && ahead > 0) {
|
|
236
|
+
warnings.push({
|
|
237
|
+
kind: 'unpushed',
|
|
238
|
+
text: ahead === 1
|
|
239
|
+
? '1 commit not pushed — the cloud clone will not have it.'
|
|
240
|
+
: `${ahead} commits not pushed — the cloud clone will not have them.`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (sameRepo && remote.dirty === true) {
|
|
244
|
+
warnings.push({ kind: 'dirty', text: 'Uncommitted changes stay local.' });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// A repository the Claude GitHub app cannot reach is refused by the cloud
|
|
249
|
+
// after the first message; the list of reachable ones says so before it.
|
|
250
|
+
if (repoAccess === 'inaccessible') {
|
|
251
|
+
const typedRepo = normalizeCloudRepoInput(repo);
|
|
252
|
+
warnings.push({
|
|
253
|
+
kind: 'not-accessible',
|
|
254
|
+
text: `The Claude GitHub app has no access to ${typedRepo?.slug || 'this repository'} — add it under `
|
|
255
|
+
+ 'GitHub → Settings → Applications → Claude, or pick another repository.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (checkEnvironment && !String(environmentId || '').trim()) {
|
|
259
|
+
warnings.push({
|
|
260
|
+
kind: 'no-environment',
|
|
261
|
+
text: `No cloud environment is set — choose one in ${CLAUDE_CLOUD_SETTINGS_PATH}.`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return warnings;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Repository and branch suggestions ──
|
|
268
|
+
|
|
269
|
+
const MAX_SUGGESTIONS = 8;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether the Claude GitHub app can reach the repository in the field:
|
|
273
|
+
* 'accessible', 'inaccessible', or 'unknown' while the list has not loaded,
|
|
274
|
+
* is incomplete, or the field names no repository yet. `repos` is the payload
|
|
275
|
+
* of GET /api/claude-cloud/repos.
|
|
276
|
+
*/
|
|
277
|
+
export function cloudRepoAccess(repoList = null, repo = '') {
|
|
278
|
+
const typed = normalizeCloudRepoInput(repo);
|
|
279
|
+
if (!typed || !repoList || repoList.ok !== true || repoList.complete !== true) return 'unknown';
|
|
280
|
+
const entries = Array.isArray(repoList.repos) ? repoList.repos : [];
|
|
281
|
+
const match = entries.find((entry) => String(entry?.slug || '').toLowerCase() === typed.slug.toLowerCase());
|
|
282
|
+
if (!match) return 'inaccessible';
|
|
283
|
+
return match.accessible === false ? 'inaccessible' : 'accessible';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The repositories to offer under the Repository field for what is typed so
|
|
288
|
+
* far: with nothing typed, the ones used in earlier cloud chats first, then
|
|
289
|
+
* the rest as the list has them (most recently pushed first); with text,
|
|
290
|
+
* those whose `owner/name` contains it, a match at the start of the owner or
|
|
291
|
+
* the name before one in the middle. A URL typed or pasted is matched by its
|
|
292
|
+
* `owner/name`. At most `limit` entries.
|
|
293
|
+
*/
|
|
294
|
+
export function filterCloudRepoSuggestions(repos = [], query = '', { limit = MAX_SUGGESTIONS } = {}) {
|
|
295
|
+
const entries = (Array.isArray(repos) ? repos : []).filter((entry) => entry && String(entry.slug || '').trim());
|
|
296
|
+
const raw = String(query ?? '').trim();
|
|
297
|
+
const needle = (normalizeCloudRepoInput(raw)?.slug || raw).toLowerCase();
|
|
298
|
+
if (!needle) return entries.slice(0, limit);
|
|
299
|
+
const ranked = [];
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const slug = String(entry.slug).toLowerCase();
|
|
302
|
+
const index = slug.indexOf(needle);
|
|
303
|
+
if (index < 0) continue;
|
|
304
|
+
const atStart = index === 0 || slug[index - 1] === '/';
|
|
305
|
+
ranked.push({ entry, rank: slug === needle ? 0 : atStart ? 1 : 2 });
|
|
306
|
+
}
|
|
307
|
+
// A stable sort: the list's own order breaks ties.
|
|
308
|
+
return ranked.sort((left, right) => left.rank - right.rank).map(({ entry }) => entry).slice(0, limit);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The second line of a repository suggestion: visibility, and that it was used here before. */
|
|
312
|
+
export function cloudRepoSuggestionMeta(entry = null) {
|
|
313
|
+
const parts = [];
|
|
314
|
+
if (entry?.recentAt) parts.push('used here');
|
|
315
|
+
if (entry?.accessible === false) parts.push('no access');
|
|
316
|
+
else if (entry?.private === true) parts.push('private');
|
|
317
|
+
else if (entry?.private === false) parts.push('public');
|
|
318
|
+
if (entry?.archived === true) parts.push('archived');
|
|
319
|
+
return parts.join(' · ');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The branches to offer under the Branch field: with nothing typed, the
|
|
324
|
+
* default branch first and the rest in the order the relay sent them (the
|
|
325
|
+
* default first, then alphabetical); with text, the names that contain it,
|
|
326
|
+
* a match at the start first.
|
|
327
|
+
*/
|
|
328
|
+
export function filterCloudBranchSuggestions(branches = [], query = '', { defaultBranch = '', limit = MAX_SUGGESTIONS } = {}) {
|
|
329
|
+
const names = [...new Set((Array.isArray(branches) ? branches : []).map((name) => String(name || '').trim()).filter(Boolean))];
|
|
330
|
+
const preferred = String(defaultBranch || '').trim();
|
|
331
|
+
const ordered = preferred && names.includes(preferred)
|
|
332
|
+
? [preferred, ...names.filter((name) => name !== preferred)]
|
|
333
|
+
: names;
|
|
334
|
+
const needle = String(query ?? '').trim().toLowerCase();
|
|
335
|
+
if (!needle) return ordered.slice(0, limit);
|
|
336
|
+
const ranked = [];
|
|
337
|
+
for (const name of ordered) {
|
|
338
|
+
const index = name.toLowerCase().indexOf(needle);
|
|
339
|
+
if (index < 0) continue;
|
|
340
|
+
ranked.push({ name, rank: name.toLowerCase() === needle ? 0 : index === 0 ? 1 : 2 });
|
|
341
|
+
}
|
|
342
|
+
return ranked.sort((left, right) => left.rank - right.rank).map(({ name }) => name).slice(0, limit);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const BOOTSTRAP_ERROR_FALLBACKS = Object.freeze({
|
|
346
|
+
claude_cloud_disabled: {
|
|
347
|
+
field: null,
|
|
348
|
+
text: `Claude Cloud is turned off. Enable it in ${CLAUDE_CLOUD_SETTINGS_PATH}.`,
|
|
349
|
+
},
|
|
350
|
+
claude_cloud_repo_invalid: {
|
|
351
|
+
field: 'repo',
|
|
352
|
+
text: 'The relay did not accept this repository. Use a GitHub repository.',
|
|
353
|
+
},
|
|
354
|
+
claude_cloud_branch_invalid: {
|
|
355
|
+
field: 'branch',
|
|
356
|
+
text: 'The relay did not accept this branch name.',
|
|
357
|
+
},
|
|
358
|
+
claude_cloud_environment_missing: {
|
|
359
|
+
field: null,
|
|
360
|
+
text: `No cloud environment is set. Choose one in ${CLAUDE_CLOUD_SETTINGS_PATH}.`,
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A rejected bootstrap as `{ code, field, text }` for the inline error line,
|
|
366
|
+
* or null when the rejection is not a Claude Cloud one (the caller keeps its
|
|
367
|
+
* usual notice then). The server's own wording wins when it sent any.
|
|
368
|
+
*/
|
|
369
|
+
export function cloudBootstrapErrorModel({ code = '', message = '' } = {}) {
|
|
370
|
+
const normalizedCode = String(code || '').trim().toLowerCase();
|
|
371
|
+
if (!normalizedCode.startsWith('claude_cloud_')) return null;
|
|
372
|
+
const fallback = BOOTSTRAP_ERROR_FALLBACKS[normalizedCode] || { field: null, text: '' };
|
|
373
|
+
const text = oneLine(message) || fallback.text || 'The relay could not start this cloud chat.';
|
|
374
|
+
return { code: normalizedCode, field: fallback.field, text };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── New Chat rows and models ──
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Which optional rows of the New Chat modal a provider shows. `contextTier`
|
|
381
|
+
* only says the row may appear: newConversationContextTierState still decides
|
|
382
|
+
* per model.
|
|
383
|
+
*/
|
|
384
|
+
export function newChatRowVisibility(providerType = '') {
|
|
385
|
+
const provider = String(providerType || '').trim().toLowerCase();
|
|
386
|
+
const cloud = provider === CLAUDE_CLOUD_PROVIDER;
|
|
387
|
+
return {
|
|
388
|
+
cloudSource: cloud,
|
|
389
|
+
reasoning: !cloud,
|
|
390
|
+
contextTier: !cloud,
|
|
391
|
+
imageSize: provider === 'openai-image',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function uniqueModelIds(values = []) {
|
|
396
|
+
const seen = new Set();
|
|
397
|
+
const out = [];
|
|
398
|
+
for (const value of Array.isArray(values) ? values : []) {
|
|
399
|
+
const id = String(value || '').trim();
|
|
400
|
+
const key = id.toLowerCase();
|
|
401
|
+
if (!id || key === 'auto' || seen.has(key)) continue;
|
|
402
|
+
seen.add(key);
|
|
403
|
+
out.push(id);
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The models a cloud chat may start with: the tab's list plus its default. */
|
|
409
|
+
export function claudeCloudModelIds(settings = null) {
|
|
410
|
+
return uniqueModelIds([
|
|
411
|
+
...(Array.isArray(settings?.models) ? settings.models : []),
|
|
412
|
+
settings?.defaultModel,
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export function claudeCloudDefaultModel(settings = null, models = claudeCloudModelIds(settings)) {
|
|
417
|
+
const preferred = String(settings?.defaultModel || '').trim();
|
|
418
|
+
if (preferred && models.includes(preferred)) return preferred;
|
|
419
|
+
return models[0] || '';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function plainModelId(modelId) {
|
|
423
|
+
// Cloud model ids are plain: "claude-opus-5[1m]" is offered as "claude-opus-5".
|
|
424
|
+
return String(modelId || '').trim().replace(/\s*\[[^\]]*\]$/, '').trim();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The composer's model list for a cloud conversation. The settings list is the
|
|
429
|
+
* authority; until it has loaded (or on a relay that does not tag models for
|
|
430
|
+
* this provider) the Claude rows of the shared catalog stand in. The
|
|
431
|
+
* conversation's own model is always on the list, so the picker can never
|
|
432
|
+
* show (or send) another provider's model for a cloud chat.
|
|
433
|
+
*/
|
|
434
|
+
export function claudeCloudComposerModelIds({
|
|
435
|
+
settings = null,
|
|
436
|
+
catalogModels = [],
|
|
437
|
+
providersByModel = {},
|
|
438
|
+
conversationModel = '',
|
|
439
|
+
} = {}) {
|
|
440
|
+
const own = plainModelId(conversationModel);
|
|
441
|
+
const fromSettings = claudeCloudModelIds(settings);
|
|
442
|
+
if (fromSettings.length) return uniqueModelIds([...fromSettings, own]);
|
|
443
|
+
const servedBy = (modelId) => {
|
|
444
|
+
const providers = providersByModel?.[String(modelId || '').trim().toLowerCase()];
|
|
445
|
+
return Array.isArray(providers) ? providers : [];
|
|
446
|
+
};
|
|
447
|
+
return uniqueModelIds([
|
|
448
|
+
...(Array.isArray(catalogModels) ? catalogModels : [])
|
|
449
|
+
.filter((modelId) => {
|
|
450
|
+
const providers = servedBy(modelId);
|
|
451
|
+
return providers.includes(CLAUDE_CLOUD_PROVIDER) || providers.includes('claude');
|
|
452
|
+
})
|
|
453
|
+
.map(plainModelId),
|
|
454
|
+
own,
|
|
455
|
+
]);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── The conversation's cloud line ──
|
|
459
|
+
|
|
460
|
+
function encodeRefPath(ref) {
|
|
461
|
+
return String(ref || '').split('/').map((part) => encodeURIComponent(part)).join('/');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Where GitHub shows what a pushed branch changed:
|
|
466
|
+
* `https://github.com/<slug>/compare/<base>...<branch>`, the compare page
|
|
467
|
+
* against the default branch when no base is known (the chat cloned the
|
|
468
|
+
* default branch), and the branch itself when it is the base (a branch
|
|
469
|
+
* compared with itself is empty). '' when the slug or the branch is unusable.
|
|
470
|
+
*/
|
|
471
|
+
export function cloudCompareUrl(slug = '', base = '', branch = '') {
|
|
472
|
+
const repo = normalizeCloudRepoInput(`https://github.com/${String(slug ?? '').trim()}`);
|
|
473
|
+
if (!repo || !isValidCloudBranchName(branch)) return '';
|
|
474
|
+
const head = encodeRefPath(branch);
|
|
475
|
+
if (base === branch) return `${repo.repoUrl}/tree/${head}`;
|
|
476
|
+
if (!isValidCloudBranchName(base)) return `${repo.repoUrl}/compare/${head}`;
|
|
477
|
+
return `${repo.repoUrl}/compare/${encodeRefPath(base)}...${head}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function httpsUrl(value) {
|
|
481
|
+
const text = oneLine(value);
|
|
482
|
+
return /^https:\/\/[^\s"'<>]+$/i.test(text) ? text : '';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function formatCloudCost(costUsd) {
|
|
486
|
+
if (costUsd === null || costUsd === undefined || costUsd === '') return '';
|
|
487
|
+
const cost = Number(costUsd);
|
|
488
|
+
if (!Number.isFinite(cost) || cost <= 0) return '';
|
|
489
|
+
return cost < 0.01 ? '<$0.01' : `$${cost.toFixed(2)}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The display model of a conversation's `cloud` payload field, or null when it
|
|
494
|
+
* names no repository. One entry per pushed branch, newest report last; a push
|
|
495
|
+
* to the cloned branch itself has nothing to compare against, so it links to
|
|
496
|
+
* that branch instead.
|
|
497
|
+
*/
|
|
498
|
+
export function buildCloudLineModel(cloud = null) {
|
|
499
|
+
if (!cloud || typeof cloud !== 'object' || Array.isArray(cloud)) return null;
|
|
500
|
+
const repo = normalizeCloudRepoInput(cloud.repoUrl || '') || normalizeCloudRepoInput(cloud.slug || '');
|
|
501
|
+
if (!repo) return null;
|
|
502
|
+
const branch = oneLine(cloud.branch);
|
|
503
|
+
const pushedByBranch = new Map();
|
|
504
|
+
for (const entry of Array.isArray(cloud.pushedBranches) ? cloud.pushedBranches : []) {
|
|
505
|
+
const name = oneLine(typeof entry === 'string' ? entry : entry?.branch);
|
|
506
|
+
if (!name) continue;
|
|
507
|
+
// Re-inserting moves a branch pushed again to the end.
|
|
508
|
+
pushedByBranch.delete(name);
|
|
509
|
+
const url = cloudCompareUrl(repo.slug, branch, name);
|
|
510
|
+
// A name git would not accept cannot be linked; it is not a branch.
|
|
511
|
+
if (!url) continue;
|
|
512
|
+
pushedByBranch.set(name, {
|
|
513
|
+
branch: name,
|
|
514
|
+
at: oneLine(typeof entry === 'string' ? '' : entry?.at),
|
|
515
|
+
sameAsBase: name === branch,
|
|
516
|
+
url,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
slug: repo.slug,
|
|
521
|
+
repoUrl: repo.repoUrl,
|
|
522
|
+
branch,
|
|
523
|
+
sessionUrl: httpsUrl(cloud.sessionUrl),
|
|
524
|
+
pushed: Array.from(pushedByBranch.values()),
|
|
525
|
+
cost: formatCloudCost(cloud.costUsd),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** The short text for the chat header: "☁ example-org/sample-repo · main". */
|
|
530
|
+
export function cloudHeaderLabel(cloud = null) {
|
|
531
|
+
const model = buildCloudLineModel(cloud);
|
|
532
|
+
if (!model) return '';
|
|
533
|
+
return `${CLAUDE_CLOUD_MARKER} ${model.slug}${model.branch ? ` · ${model.branch}` : ''}`;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function renderCloudLineHtml(cloud = null) {
|
|
537
|
+
const model = buildCloudLineModel(cloud);
|
|
538
|
+
if (!model) return '';
|
|
539
|
+
const parts = [
|
|
540
|
+
`<a class="cloud-line-item cloud-line-repo" href="${escapeHtml(model.repoUrl)}" target="_blank" rel="noopener" title="The GitHub repository this chat works on in the cloud">${CLAUDE_CLOUD_MARKER} ${escapeHtml(model.slug)}</a>`,
|
|
541
|
+
];
|
|
542
|
+
if (model.branch) {
|
|
543
|
+
parts.push(`<span class="cloud-line-item cloud-line-branch" title="Branch the cloud session cloned">${escapeHtml(model.branch)}</span>`);
|
|
544
|
+
}
|
|
545
|
+
if (model.sessionUrl) {
|
|
546
|
+
parts.push(`<a class="cloud-line-item cloud-line-session" href="${escapeHtml(model.sessionUrl)}" target="_blank" rel="noopener" title="Open this session on claude.ai">claude.ai ↗</a>`);
|
|
547
|
+
}
|
|
548
|
+
for (const pushed of model.pushed) {
|
|
549
|
+
const title = pushed.sameAsBase
|
|
550
|
+
? `Pushed to ${pushed.branch}. Opens the branch on GitHub.`
|
|
551
|
+
: `Pushed branch. Opens the comparison with ${model.branch || 'the default branch'} on GitHub.`;
|
|
552
|
+
parts.push(`<a class="cloud-line-item cloud-line-push" href="${escapeHtml(pushed.url)}" target="_blank" rel="noopener" title="${escapeHtml(title)}">⇡ ${escapeHtml(pushed.branch)}</a>`);
|
|
553
|
+
}
|
|
554
|
+
if (model.cost) {
|
|
555
|
+
parts.push(`<span class="cloud-line-item cloud-line-cost" title="Cost of this cloud session so far, as reported by Anthropic">${escapeHtml(model.cost)}</span>`);
|
|
556
|
+
}
|
|
557
|
+
return parts.join('');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The `cloud` field after a `claude_cloud_session` event. The event carries
|
|
562
|
+
* the whole field; a payload without one leaves what the client already has.
|
|
563
|
+
*/
|
|
564
|
+
export function mergeCloudSessionUpdate(currentCloud = null, payloadCloud = undefined) {
|
|
565
|
+
if (payloadCloud === undefined) return currentCloud ?? null;
|
|
566
|
+
if (payloadCloud === null || typeof payloadCloud !== 'object' || Array.isArray(payloadCloud)) return null;
|
|
567
|
+
return { ...(currentCloud && typeof currentCloud === 'object' ? currentCloud : {}), ...payloadCloud };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── Attachments: inline images only ──
|
|
571
|
+
|
|
572
|
+
// The limits of server/claude-worker/claude-attachments.mjs, which the cloud
|
|
573
|
+
// worker applies too: anything else is refused there with a whole turn lost.
|
|
574
|
+
export const CLAUDE_CLOUD_IMAGE_TYPES = Object.freeze(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
575
|
+
export const CLAUDE_CLOUD_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
576
|
+
export const CLAUDE_CLOUD_ATTACH_ACCEPT = CLAUDE_CLOUD_IMAGE_TYPES.join(',');
|
|
577
|
+
export const CLAUDE_CLOUD_ATTACH_HINT = 'Attach image (Claude Cloud chats take images only: JPEG, PNG, GIF or WebP up to 5 MB)';
|
|
578
|
+
|
|
579
|
+
/** '' when a cloud chat can carry the attachment, else 'type' or 'size'. */
|
|
580
|
+
export function cloudAttachmentRejection(attachment = null) {
|
|
581
|
+
const type = String(attachment?.type || '').trim().toLowerCase();
|
|
582
|
+
if (!CLAUDE_CLOUD_IMAGE_TYPES.includes(type)) return 'type';
|
|
583
|
+
const size = Number(attachment?.size || 0);
|
|
584
|
+
if (Number.isFinite(size) && size > CLAUDE_CLOUD_MAX_IMAGE_BYTES) return 'size';
|
|
585
|
+
return '';
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function partitionCloudAttachments(attachments = []) {
|
|
589
|
+
const accepted = [];
|
|
590
|
+
const rejected = [];
|
|
591
|
+
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
|
592
|
+
if (!attachment) continue;
|
|
593
|
+
const reason = cloudAttachmentRejection(attachment);
|
|
594
|
+
if (reason) rejected.push({ attachment, reason });
|
|
595
|
+
else accepted.push(attachment);
|
|
596
|
+
}
|
|
597
|
+
return { accepted, rejected };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function cloudAttachmentRejectionNotice(rejected = []) {
|
|
601
|
+
const list = Array.isArray(rejected) ? rejected.filter(Boolean) : [];
|
|
602
|
+
if (!list.length) return '';
|
|
603
|
+
const names = list.map((entry) => oneLine(entry?.attachment?.name) || 'file');
|
|
604
|
+
const shown = names.slice(0, 2).join(', ');
|
|
605
|
+
const more = names.length > 2 ? ` and ${names.length - 2} more` : '';
|
|
606
|
+
const onlySize = list.every((entry) => entry?.reason === 'size');
|
|
607
|
+
const rule = onlySize
|
|
608
|
+
? 'Claude Cloud chats take images up to 5 MB.'
|
|
609
|
+
: 'Claude Cloud chats take images only (JPEG, PNG, GIF or WebP up to 5 MB).';
|
|
610
|
+
return `${rule} Not attached: ${shown}${more}.`;
|
|
611
|
+
}
|
|
@@ -27,6 +27,7 @@ const STEERING_HOLD_TITLES = Object.freeze({
|
|
|
27
27
|
compaction: 'Will steer in once the conversation finishes compacting',
|
|
28
28
|
adoption: 'Will steer in once the compaction recovery finishes',
|
|
29
29
|
delivery: 'Will steer in once the current delivery lands',
|
|
30
|
+
tool: 'Will steer in once the running tool call finishes',
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
export function deriveComposerControlState({
|
|
@@ -41,12 +42,23 @@ export function deriveComposerControlState({
|
|
|
41
42
|
// not "Queue". Workers that do not advertise it keep the queue wording.
|
|
42
43
|
steeringSupported = false,
|
|
43
44
|
// The worker reports steering as momentarily held (open question card or
|
|
44
|
-
// plan approval, compaction, post-compaction adoption
|
|
45
|
-
//
|
|
46
|
-
// hold clears.
|
|
45
|
+
// plan approval, compaction, post-compaction adoption, a running tool call
|
|
46
|
+
// that a steer would cut short). A send stays allowed and reads "Queue":
|
|
47
|
+
// the relay holds the message and it steers in once the hold clears.
|
|
47
48
|
steeringHeld = false,
|
|
48
49
|
steeringHoldReason = null,
|
|
50
|
+
// An archived conversation: its worker is stopped and it is out of the
|
|
51
|
+
// live list; sending would start it again behind the user's back.
|
|
52
|
+
archived = false,
|
|
49
53
|
} = {}) {
|
|
54
|
+
if (archived) {
|
|
55
|
+
return {
|
|
56
|
+
action: 'send',
|
|
57
|
+
label: 'Send',
|
|
58
|
+
title: 'This conversation is archived. Unarchive it to continue.',
|
|
59
|
+
disabled: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
50
62
|
const active = !!hasActiveTurn;
|
|
51
63
|
const draft = !!hasDraft;
|
|
52
64
|
const metadataBlocked = !!modelMetadataBlocked;
|