viveworker 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/README.md +5 -0
- package/package.json +20 -2
- package/scripts/viveworker-bridge.mjs +162 -7
- package/scripts/viveworker.mjs +79 -3
- package/web/app.css +84 -0
- package/web/app.js +256 -18
- package/web/i18n.js +30 -0
- package/web/sw.js +1 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuta Hoshino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# viveworker
|
|
2
4
|
|
|
5
|
+
[](https://badge.fury.io/js/viveworker)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
3
8
|
`viveworker` brings Codex Desktop to your iPhone.
|
|
4
9
|
|
|
5
10
|
When Codex needs an approval, asks whether to implement a plan, wants you to choose from options, or finishes a task while you are away from your desk, `viveworker` keeps all of that within reach on your phone. Instead of breaking your rhythm, it helps you keep vivecoding going from anywhere in your home or office.
|
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "viveworker",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Local iPhone companion for Codex Desktop approvals, plan checks, questions, and notifications on your LAN.",
|
|
5
|
+
"author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"codex",
|
|
9
|
+
"codex-desktop",
|
|
10
|
+
"macos",
|
|
11
|
+
"iphone",
|
|
12
|
+
"ios",
|
|
13
|
+
"pwa",
|
|
14
|
+
"web-push",
|
|
15
|
+
"notifications",
|
|
16
|
+
"approvals",
|
|
17
|
+
"lan",
|
|
18
|
+
"companion-app",
|
|
19
|
+
"vivecoding"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://lp.hazbase.com/",
|
|
22
|
+
"repository": "viveworker-dev/viveworker",
|
|
5
23
|
"type": "module",
|
|
6
24
|
"bin": {
|
|
7
25
|
"viveworker": "./scripts/viveworker.mjs"
|
|
@@ -31,6 +31,9 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
31
31
|
const MAX_PAIRED_DEVICES = 200;
|
|
32
32
|
const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
33
33
|
const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
|
|
34
|
+
const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
|
|
36
|
+
const MAX_COMPLETION_REPLY_IMAGE_COUNT = 1;
|
|
34
37
|
|
|
35
38
|
const cli = parseCliArgs(process.argv.slice(2));
|
|
36
39
|
const envFile = resolveEnvFile(cli.envFile);
|
|
@@ -5437,8 +5440,25 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
|
|
|
5437
5440
|
}
|
|
5438
5441
|
}
|
|
5439
5442
|
|
|
5440
|
-
|
|
5443
|
+
function normalizeCompletionReplyLocalImagePaths(paths) {
|
|
5444
|
+
if (!Array.isArray(paths)) {
|
|
5445
|
+
return [];
|
|
5446
|
+
}
|
|
5447
|
+
return paths
|
|
5448
|
+
.map((value) => resolvePath(cleanText(value || "")))
|
|
5449
|
+
.filter(Boolean);
|
|
5450
|
+
}
|
|
5451
|
+
|
|
5452
|
+
async function handleCompletionReply({
|
|
5453
|
+
runtime,
|
|
5454
|
+
completionItem,
|
|
5455
|
+
text,
|
|
5456
|
+
planMode = false,
|
|
5457
|
+
force = false,
|
|
5458
|
+
localImagePaths = [],
|
|
5459
|
+
}) {
|
|
5441
5460
|
const messageText = cleanText(text ?? "");
|
|
5461
|
+
const normalizedLocalImagePaths = normalizeCompletionReplyLocalImagePaths(localImagePaths);
|
|
5442
5462
|
if (!messageText) {
|
|
5443
5463
|
throw new Error("completion-reply-empty");
|
|
5444
5464
|
}
|
|
@@ -5472,6 +5492,10 @@ async function handleCompletionReply({ runtime, completionItem, text, planMode =
|
|
|
5472
5492
|
const turnStartParams = {
|
|
5473
5493
|
input: buildTextInput(messageText),
|
|
5474
5494
|
attachments: [],
|
|
5495
|
+
localImagePaths: normalizedLocalImagePaths,
|
|
5496
|
+
local_image_paths: normalizedLocalImagePaths,
|
|
5497
|
+
remoteImageUrls: [],
|
|
5498
|
+
remote_image_urls: [],
|
|
5475
5499
|
cwd: null,
|
|
5476
5500
|
approvalPolicy: null,
|
|
5477
5501
|
sandboxPolicy: null,
|
|
@@ -6112,22 +6136,36 @@ function createNativeApprovalServer({ config, runtime, state }) {
|
|
|
6112
6136
|
}
|
|
6113
6137
|
|
|
6114
6138
|
try {
|
|
6115
|
-
const
|
|
6139
|
+
const contentType = String(req.headers["content-type"] || "");
|
|
6140
|
+
const payload = contentType.includes("multipart/form-data")
|
|
6141
|
+
? await stageCompletionReplyImages(config, req)
|
|
6142
|
+
: await parseJsonBody(req);
|
|
6116
6143
|
await handleCompletionReply({
|
|
6117
6144
|
runtime,
|
|
6118
6145
|
completionItem,
|
|
6119
6146
|
text: payload?.text ?? "",
|
|
6120
6147
|
planMode: payload?.planMode === true,
|
|
6121
6148
|
force: payload?.force === true,
|
|
6149
|
+
localImagePaths: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths : [],
|
|
6122
6150
|
});
|
|
6123
6151
|
return writeJson(res, 200, {
|
|
6124
6152
|
ok: true,
|
|
6125
6153
|
planMode: payload?.planMode === true,
|
|
6154
|
+
imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
|
|
6126
6155
|
});
|
|
6127
6156
|
} catch (error) {
|
|
6128
6157
|
if (error.message === "completion-reply-empty") {
|
|
6129
6158
|
return writeJson(res, 400, { error: error.message });
|
|
6130
6159
|
}
|
|
6160
|
+
if (
|
|
6161
|
+
error.message === "completion-reply-image-limit" ||
|
|
6162
|
+
error.message === "completion-reply-image-invalid-type" ||
|
|
6163
|
+
error.message === "completion-reply-image-too-large" ||
|
|
6164
|
+
error.message === "completion-reply-image-invalid-upload" ||
|
|
6165
|
+
error.message === "completion-reply-image-disabled"
|
|
6166
|
+
) {
|
|
6167
|
+
return writeJson(res, 400, { error: error.message });
|
|
6168
|
+
}
|
|
6131
6169
|
if (error.message === "completion-reply-unavailable") {
|
|
6132
6170
|
return writeJson(res, 409, { error: error.message });
|
|
6133
6171
|
}
|
|
@@ -6766,6 +6804,20 @@ async function parseFormBody(req) {
|
|
|
6766
6804
|
});
|
|
6767
6805
|
}
|
|
6768
6806
|
|
|
6807
|
+
async function parseMultipartBody(req) {
|
|
6808
|
+
const contentLength = Number(req.headers["content-length"]) || 0;
|
|
6809
|
+
if (contentLength > 32 * 1024 * 1024) {
|
|
6810
|
+
throw new Error("request-body-too-large");
|
|
6811
|
+
}
|
|
6812
|
+
const request = new Request("http://localhost/upload", {
|
|
6813
|
+
method: req.method || "POST",
|
|
6814
|
+
headers: req.headers,
|
|
6815
|
+
body: req,
|
|
6816
|
+
duplex: "half",
|
|
6817
|
+
});
|
|
6818
|
+
return request.formData();
|
|
6819
|
+
}
|
|
6820
|
+
|
|
6769
6821
|
async function parseJsonBody(req) {
|
|
6770
6822
|
return new Promise((resolve, reject) => {
|
|
6771
6823
|
let body = "";
|
|
@@ -6788,6 +6840,94 @@ async function parseJsonBody(req) {
|
|
|
6788
6840
|
});
|
|
6789
6841
|
}
|
|
6790
6842
|
|
|
6843
|
+
function guessUploadExtension(fileName, mimeType) {
|
|
6844
|
+
const explicitExtension = path.extname(cleanText(fileName || ""));
|
|
6845
|
+
if (explicitExtension) {
|
|
6846
|
+
return explicitExtension.toLowerCase();
|
|
6847
|
+
}
|
|
6848
|
+
const normalizedMimeType = cleanText(mimeType || "").toLowerCase();
|
|
6849
|
+
const knownExtensions = {
|
|
6850
|
+
"image/jpeg": ".jpg",
|
|
6851
|
+
"image/png": ".png",
|
|
6852
|
+
"image/webp": ".webp",
|
|
6853
|
+
"image/gif": ".gif",
|
|
6854
|
+
"image/heic": ".heic",
|
|
6855
|
+
"image/heif": ".heif",
|
|
6856
|
+
};
|
|
6857
|
+
return knownExtensions[normalizedMimeType] || ".img";
|
|
6858
|
+
}
|
|
6859
|
+
|
|
6860
|
+
async function cleanupExpiredCompletionReplyUploads(config) {
|
|
6861
|
+
try {
|
|
6862
|
+
const entries = await fs.readdir(config.replyUploadsDir, { withFileTypes: true });
|
|
6863
|
+
const cutoffMs = Date.now() - config.completionReplyUploadTtlMs;
|
|
6864
|
+
await Promise.all(entries.map(async (entry) => {
|
|
6865
|
+
if (!entry.isFile()) {
|
|
6866
|
+
return;
|
|
6867
|
+
}
|
|
6868
|
+
const filePath = path.join(config.replyUploadsDir, entry.name);
|
|
6869
|
+
try {
|
|
6870
|
+
const stat = await fs.stat(filePath);
|
|
6871
|
+
if (Number(stat.mtimeMs) < cutoffMs) {
|
|
6872
|
+
await fs.rm(filePath, { force: true });
|
|
6873
|
+
}
|
|
6874
|
+
} catch {
|
|
6875
|
+
// Ignore best-effort cleanup errors.
|
|
6876
|
+
}
|
|
6877
|
+
}));
|
|
6878
|
+
} catch {
|
|
6879
|
+
// Ignore missing upload dir.
|
|
6880
|
+
}
|
|
6881
|
+
}
|
|
6882
|
+
|
|
6883
|
+
async function stageCompletionReplyImages(config, req) {
|
|
6884
|
+
const formData = await parseMultipartBody(req);
|
|
6885
|
+
const files = formData
|
|
6886
|
+
.getAll("image")
|
|
6887
|
+
.filter((value) => typeof File !== "undefined" && value instanceof File);
|
|
6888
|
+
|
|
6889
|
+
if (files.length > 0) {
|
|
6890
|
+
throw new Error("completion-reply-image-disabled");
|
|
6891
|
+
}
|
|
6892
|
+
|
|
6893
|
+
if (files.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
|
|
6894
|
+
throw new Error("completion-reply-image-limit");
|
|
6895
|
+
}
|
|
6896
|
+
|
|
6897
|
+
await cleanupExpiredCompletionReplyUploads(config);
|
|
6898
|
+
await fs.mkdir(config.replyUploadsDir, { recursive: true });
|
|
6899
|
+
|
|
6900
|
+
const localImagePaths = [];
|
|
6901
|
+
for (const file of files) {
|
|
6902
|
+
const mimeType = cleanText(file.type || "").toLowerCase();
|
|
6903
|
+
if (!mimeType.startsWith("image/")) {
|
|
6904
|
+
throw new Error("completion-reply-image-invalid-type");
|
|
6905
|
+
}
|
|
6906
|
+
if (!Number.isFinite(file.size) || file.size <= 0) {
|
|
6907
|
+
throw new Error("completion-reply-image-invalid-upload");
|
|
6908
|
+
}
|
|
6909
|
+
if (file.size > config.completionReplyImageMaxBytes) {
|
|
6910
|
+
throw new Error("completion-reply-image-too-large");
|
|
6911
|
+
}
|
|
6912
|
+
|
|
6913
|
+
const extension = guessUploadExtension(file.name, mimeType);
|
|
6914
|
+
const stagedFilePath = path.join(
|
|
6915
|
+
config.replyUploadsDir,
|
|
6916
|
+
`${Date.now()}-${crypto.randomUUID()}${extension}`
|
|
6917
|
+
);
|
|
6918
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
6919
|
+
await fs.writeFile(stagedFilePath, buffer, { mode: 0o600 });
|
|
6920
|
+
localImagePaths.push(stagedFilePath);
|
|
6921
|
+
}
|
|
6922
|
+
|
|
6923
|
+
return {
|
|
6924
|
+
text: cleanText(formData.get("text") ?? ""),
|
|
6925
|
+
planMode: String(formData.get("planMode") ?? "") === "true",
|
|
6926
|
+
force: String(formData.get("force") ?? "") === "true",
|
|
6927
|
+
localImagePaths,
|
|
6928
|
+
};
|
|
6929
|
+
}
|
|
6930
|
+
|
|
6791
6931
|
function isLoopbackRequest(req) {
|
|
6792
6932
|
const remoteAddress = cleanText(req.socket?.remoteAddress ?? "");
|
|
6793
6933
|
return (
|
|
@@ -7727,6 +7867,7 @@ function isLoopbackHostname(value) {
|
|
|
7727
7867
|
|
|
7728
7868
|
function buildConfig(cli) {
|
|
7729
7869
|
const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
|
7870
|
+
const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
|
|
7730
7871
|
return {
|
|
7731
7872
|
dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
|
|
7732
7873
|
once: cli.once,
|
|
@@ -7740,7 +7881,8 @@ function buildConfig(cli) {
|
|
|
7740
7881
|
sessionIndexFile: resolvePath(process.env.SESSION_INDEX_FILE || path.join(codexHome, "session_index.jsonl")),
|
|
7741
7882
|
historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
|
|
7742
7883
|
codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
|
|
7743
|
-
stateFile
|
|
7884
|
+
stateFile,
|
|
7885
|
+
replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
|
|
7744
7886
|
pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
|
|
7745
7887
|
replaySeconds: numberEnv("REPLAY_SECONDS", 300),
|
|
7746
7888
|
sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
|
|
@@ -7794,6 +7936,14 @@ function buildConfig(cli) {
|
|
|
7794
7936
|
ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
|
|
7795
7937
|
ipcRequestTimeoutMs: numberEnv("IPC_REQUEST_TIMEOUT_MS", 12000),
|
|
7796
7938
|
choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
|
|
7939
|
+
completionReplyImageMaxBytes: numberEnv(
|
|
7940
|
+
"COMPLETION_REPLY_IMAGE_MAX_BYTES",
|
|
7941
|
+
DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES
|
|
7942
|
+
),
|
|
7943
|
+
completionReplyUploadTtlMs: numberEnv(
|
|
7944
|
+
"COMPLETION_REPLY_UPLOAD_TTL_MS",
|
|
7945
|
+
DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS
|
|
7946
|
+
),
|
|
7797
7947
|
deviceTrustTtlMs: numberEnv("DEVICE_TRUST_TTL_MS", DEFAULT_DEVICE_TRUST_TTL_MS),
|
|
7798
7948
|
sessionTtlMs: numberEnv("SESSION_TTL_MS", 30 * 24 * 60 * 60 * 1000),
|
|
7799
7949
|
pairingCode: process.env.PAIRING_CODE || "",
|
|
@@ -7907,9 +8057,7 @@ function loadEnvFile(filePath) {
|
|
|
7907
8057
|
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
7908
8058
|
value = value.slice(1, -1);
|
|
7909
8059
|
}
|
|
7910
|
-
|
|
7911
|
-
process.env[key] = value;
|
|
7912
|
-
}
|
|
8060
|
+
process.env[key] = value;
|
|
7913
8061
|
}
|
|
7914
8062
|
} catch {
|
|
7915
8063
|
// Optional env file.
|
|
@@ -8502,7 +8650,7 @@ function summarizeNotificationText(value) {
|
|
|
8502
8650
|
}
|
|
8503
8651
|
|
|
8504
8652
|
function normalizeLongText(value) {
|
|
8505
|
-
return String(stripMarkdownLinks(value) || "")
|
|
8653
|
+
return String(stripEnvironmentContextBlocks(stripMarkdownLinks(value)) || "")
|
|
8506
8654
|
.replace(/\r\n/gu, "\n")
|
|
8507
8655
|
.replace(/[ \t]+\n/gu, "\n")
|
|
8508
8656
|
.replace(/\n{3,}/gu, "\n\n")
|
|
@@ -8575,6 +8723,13 @@ function stripMarkdownLinks(value) {
|
|
|
8575
8723
|
return String(value || "").replace(/\[([^\]]+)\]\(([^)]+)\)/gu, "$1");
|
|
8576
8724
|
}
|
|
8577
8725
|
|
|
8726
|
+
function stripEnvironmentContextBlocks(value) {
|
|
8727
|
+
return String(value || "")
|
|
8728
|
+
.replace(/<environment_context>[\s\S]*?<\/environment_context>\s*/giu, "")
|
|
8729
|
+
.replace(/^\s*<environment_context>[\s\S]*$/giu, "")
|
|
8730
|
+
.replace(/^\s*environment_context\s*$/gimu, "");
|
|
8731
|
+
}
|
|
8732
|
+
|
|
8578
8733
|
function stripNotificationMarkup(value) {
|
|
8579
8734
|
return String(value || "")
|
|
8580
8735
|
.replace(/^\s*<\/?proposed_plan>\s*$/gimu, "")
|
package/scripts/viveworker.mjs
CHANGED
|
@@ -200,7 +200,14 @@ async function runSetup(cliOptions) {
|
|
|
200
200
|
|
|
201
201
|
progress.update("cli.setup.progress.health");
|
|
202
202
|
const healthy = await waitForHealth(buildLoopbackHealthUrl(publicBaseUrl));
|
|
203
|
-
|
|
203
|
+
const pairingReady = healthy
|
|
204
|
+
? await waitForExpectedPairing(publicBaseUrl, pairToken)
|
|
205
|
+
: false;
|
|
206
|
+
progress.done(healthy && pairingReady ? "cli.setup.complete" : "cli.setup.completePending");
|
|
207
|
+
if (healthy && !pairingReady) {
|
|
208
|
+
console.log("");
|
|
209
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port }));
|
|
210
|
+
}
|
|
204
211
|
|
|
205
212
|
const pairPath = `/app?pairToken=${encodeURIComponent(pairToken)}`;
|
|
206
213
|
const mkcertRootCaFile = resolvePath(
|
|
@@ -309,7 +316,14 @@ async function runStart(cliOptions) {
|
|
|
309
316
|
await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`]);
|
|
310
317
|
progress.update("cli.start.progress.health");
|
|
311
318
|
const healthy = await waitForHealth(healthUrl);
|
|
312
|
-
|
|
319
|
+
const pairingReady = healthy && rotatedPairing.rotated
|
|
320
|
+
? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
|
|
321
|
+
: true;
|
|
322
|
+
progress.done(healthy && pairingReady ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
|
|
323
|
+
if (healthy && !pairingReady) {
|
|
324
|
+
console.log("");
|
|
325
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
|
|
326
|
+
}
|
|
313
327
|
if (rotatedPairing.rotated) {
|
|
314
328
|
await printPairingInfo(locale, config);
|
|
315
329
|
}
|
|
@@ -324,7 +338,14 @@ async function runStart(cliOptions) {
|
|
|
324
338
|
});
|
|
325
339
|
progress.update("cli.start.progress.health");
|
|
326
340
|
const healthy = await waitForHealth(healthUrl);
|
|
327
|
-
|
|
341
|
+
const pairingReady = healthy && rotatedPairing.rotated
|
|
342
|
+
? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
|
|
343
|
+
: true;
|
|
344
|
+
progress.done(healthy && pairingReady ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
|
|
345
|
+
if (healthy && !pairingReady) {
|
|
346
|
+
console.log("");
|
|
347
|
+
console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
|
|
348
|
+
}
|
|
328
349
|
if (rotatedPairing.rotated) {
|
|
329
350
|
await printPairingInfo(locale, config);
|
|
330
351
|
}
|
|
@@ -982,6 +1003,33 @@ function buildLoopbackHealthUrl(baseUrl) {
|
|
|
982
1003
|
}
|
|
983
1004
|
}
|
|
984
1005
|
|
|
1006
|
+
function buildLoopbackUrl(baseUrl, pathname, searchParams = null) {
|
|
1007
|
+
if (!baseUrl) {
|
|
1008
|
+
return "";
|
|
1009
|
+
}
|
|
1010
|
+
try {
|
|
1011
|
+
const url = new URL(baseUrl);
|
|
1012
|
+
url.hostname = "127.0.0.1";
|
|
1013
|
+
url.pathname = pathname;
|
|
1014
|
+
url.search = "";
|
|
1015
|
+
url.hash = "";
|
|
1016
|
+
if (searchParams && Object.keys(searchParams).length > 0) {
|
|
1017
|
+
const params = new URLSearchParams();
|
|
1018
|
+
for (const [key, value] of Object.entries(searchParams)) {
|
|
1019
|
+
if (value == null || value === "") {
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
params.set(key, String(value));
|
|
1023
|
+
}
|
|
1024
|
+
const serialized = params.toString();
|
|
1025
|
+
url.search = serialized ? `?${serialized}` : "";
|
|
1026
|
+
}
|
|
1027
|
+
return url.toString();
|
|
1028
|
+
} catch {
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
985
1033
|
function sleep(ms) {
|
|
986
1034
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
987
1035
|
}
|
|
@@ -1105,6 +1153,34 @@ async function waitForHealth(url, { attempts = 8, intervalMs = 500 } = {}) {
|
|
|
1105
1153
|
return false;
|
|
1106
1154
|
}
|
|
1107
1155
|
|
|
1156
|
+
async function waitForExpectedPairing(baseUrl, pairToken, { attempts = 8, intervalMs = 500 } = {}) {
|
|
1157
|
+
const token = String(pairToken || "").trim();
|
|
1158
|
+
const manifestUrl = buildLoopbackUrl(baseUrl, "/manifest.webmanifest", { pairToken: token });
|
|
1159
|
+
const expectedStartUrl = `/app?pairToken=${encodeURIComponent(token)}`;
|
|
1160
|
+
if (!token || !manifestUrl) {
|
|
1161
|
+
return false;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1165
|
+
const result = await execCommand(buildHealthCheckArgs(manifestUrl), { ignoreError: true });
|
|
1166
|
+
if (result.ok) {
|
|
1167
|
+
try {
|
|
1168
|
+
const payload = JSON.parse(result.stdout);
|
|
1169
|
+
if (String(payload?.start_url || "").trim() === expectedStartUrl) {
|
|
1170
|
+
return true;
|
|
1171
|
+
}
|
|
1172
|
+
} catch {
|
|
1173
|
+
// Keep retrying while the new bridge instance comes up.
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (attempt < attempts - 1) {
|
|
1177
|
+
await sleep(intervalMs);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return false;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1108
1184
|
function truthyString(value) {
|
|
1109
1185
|
return /^(1|true|yes|on)$/iu.test(String(value || "").trim());
|
|
1110
1186
|
}
|
package/web/app.css
CHANGED
|
@@ -1541,6 +1541,90 @@ code {
|
|
|
1541
1541
|
color: rgba(205, 220, 231, 0.42);
|
|
1542
1542
|
}
|
|
1543
1543
|
|
|
1544
|
+
.reply-attachment-field {
|
|
1545
|
+
display: grid;
|
|
1546
|
+
gap: 0.55rem;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
.reply-attachment-field__header {
|
|
1550
|
+
display: flex;
|
|
1551
|
+
align-items: center;
|
|
1552
|
+
justify-content: space-between;
|
|
1553
|
+
gap: 0.75rem;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.reply-attachment-picker {
|
|
1557
|
+
display: grid;
|
|
1558
|
+
gap: 0.18rem;
|
|
1559
|
+
padding: 0.88rem 0.95rem;
|
|
1560
|
+
border-radius: 18px;
|
|
1561
|
+
border: 1px dashed rgba(156, 181, 197, 0.24);
|
|
1562
|
+
background: rgba(255, 255, 255, 0.03);
|
|
1563
|
+
cursor: pointer;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
.reply-attachment-picker__input {
|
|
1567
|
+
position: absolute;
|
|
1568
|
+
opacity: 0;
|
|
1569
|
+
pointer-events: none;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
.reply-attachment-picker__label {
|
|
1573
|
+
font-size: 0.93rem;
|
|
1574
|
+
font-weight: 700;
|
|
1575
|
+
color: #eef8ff;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
.reply-attachment-picker__hint {
|
|
1579
|
+
font-size: 0.82rem;
|
|
1580
|
+
color: var(--muted);
|
|
1581
|
+
line-height: 1.42;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.reply-image-preview {
|
|
1585
|
+
display: grid;
|
|
1586
|
+
grid-template-columns: 4.5rem minmax(0, 1fr);
|
|
1587
|
+
gap: 0.8rem;
|
|
1588
|
+
align-items: center;
|
|
1589
|
+
padding: 0.78rem 0.85rem;
|
|
1590
|
+
border-radius: 18px;
|
|
1591
|
+
border: 1px solid rgba(156, 181, 197, 0.12);
|
|
1592
|
+
background: rgba(255, 255, 255, 0.04);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
.reply-image-preview__image {
|
|
1596
|
+
width: 4.5rem;
|
|
1597
|
+
height: 4.5rem;
|
|
1598
|
+
object-fit: cover;
|
|
1599
|
+
border-radius: 14px;
|
|
1600
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
1601
|
+
background: rgba(6, 10, 14, 0.92);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
.reply-image-preview__copy {
|
|
1605
|
+
min-width: 0;
|
|
1606
|
+
display: grid;
|
|
1607
|
+
gap: 0.14rem;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
.reply-image-preview__name,
|
|
1611
|
+
.reply-image-preview__meta {
|
|
1612
|
+
margin: 0;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
.reply-image-preview__name {
|
|
1616
|
+
color: #eef8ff;
|
|
1617
|
+
font-size: 0.91rem;
|
|
1618
|
+
font-weight: 600;
|
|
1619
|
+
line-height: 1.35;
|
|
1620
|
+
overflow-wrap: anywhere;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
.reply-image-preview__meta {
|
|
1624
|
+
color: var(--muted);
|
|
1625
|
+
font-size: 0.8rem;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1544
1628
|
.reply-mode-switch {
|
|
1545
1629
|
display: grid;
|
|
1546
1630
|
grid-template-columns: auto auto minmax(0, 1fr);
|
package/web/app.js
CHANGED
|
@@ -6,6 +6,7 @@ const PUSH_BANNER_DISMISS_KEY = "viveworker-push-banner-dismissed-v1";
|
|
|
6
6
|
const INITIAL_DETECTED_LOCALE = detectBrowserLocale();
|
|
7
7
|
const TIMELINE_MESSAGE_KINDS = new Set(["user_message", "assistant_commentary", "assistant_final"]);
|
|
8
8
|
const TIMELINE_OPERATIONAL_KINDS = new Set(["approval", "plan", "plan_ready", "choice", "completion"]);
|
|
9
|
+
const THREAD_FILTER_INTERACTION_DEFER_MS = 8000;
|
|
9
10
|
|
|
10
11
|
const state = {
|
|
11
12
|
session: null,
|
|
@@ -29,6 +30,7 @@ const state = {
|
|
|
29
30
|
pendingDetailScrollReset: false,
|
|
30
31
|
listScrollState: null,
|
|
31
32
|
pendingListScrollRestore: false,
|
|
33
|
+
threadFilterInteractionUntilMs: 0,
|
|
32
34
|
choiceLocalDrafts: {},
|
|
33
35
|
completionReplyDrafts: {},
|
|
34
36
|
pairError: "",
|
|
@@ -85,7 +87,7 @@ async function boot() {
|
|
|
85
87
|
|
|
86
88
|
await refreshSession();
|
|
87
89
|
|
|
88
|
-
if (!state.session?.authenticated && initialPairToken) {
|
|
90
|
+
if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
|
|
89
91
|
try {
|
|
90
92
|
await pair({ token: initialPairToken });
|
|
91
93
|
} catch (error) {
|
|
@@ -124,7 +126,7 @@ async function boot() {
|
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
await refreshAuthenticatedState();
|
|
127
|
-
if (!
|
|
129
|
+
if (!shouldDeferRenderForActiveInteraction()) {
|
|
128
130
|
await renderShell();
|
|
129
131
|
}
|
|
130
132
|
}, 3000);
|
|
@@ -478,6 +480,7 @@ function syncCompletedThreadFilter() {
|
|
|
478
480
|
}
|
|
479
481
|
|
|
480
482
|
function renderPair() {
|
|
483
|
+
const shouldInstallFromHomeScreen = Boolean(initialPairToken) && !shouldAutoPairFromBootstrapToken();
|
|
481
484
|
app.innerHTML = `
|
|
482
485
|
<main class="onboarding-shell">
|
|
483
486
|
<section class="onboarding-card">
|
|
@@ -486,6 +489,7 @@ function renderPair() {
|
|
|
486
489
|
<p class="hero-copy">${escapeHtml(L("pair.copy"))}</p>
|
|
487
490
|
${state.pairNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pairNotice)}</p>` : ""}
|
|
488
491
|
${state.pairError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pairError)}</p>` : ""}
|
|
492
|
+
${shouldInstallFromHomeScreen ? `<p class="inline-alert inline-alert--warning">${escapeHtml(L("pair.installFromHomeScreen"))}</p>` : ""}
|
|
489
493
|
<form id="pair-form" class="pair-form">
|
|
490
494
|
<label class="field">
|
|
491
495
|
<span class="field-label">${escapeHtml(L("pair.codeLabel"))}</span>
|
|
@@ -553,6 +557,7 @@ function resetAuthenticatedState() {
|
|
|
553
557
|
state.detailLoadingItem = null;
|
|
554
558
|
state.detailOpen = false;
|
|
555
559
|
state.choiceLocalDrafts = {};
|
|
560
|
+
clearAllCompletionReplyDrafts();
|
|
556
561
|
state.completionReplyDrafts = {};
|
|
557
562
|
state.settingsSubpage = "";
|
|
558
563
|
state.settingsScrollState = null;
|
|
@@ -674,15 +679,30 @@ function currentViewportScrollY() {
|
|
|
674
679
|
return window.scrollY || window.pageYOffset || document.documentElement?.scrollTop || 0;
|
|
675
680
|
}
|
|
676
681
|
|
|
677
|
-
function
|
|
682
|
+
function markThreadFilterInteraction() {
|
|
683
|
+
state.threadFilterInteractionUntilMs = Date.now() + THREAD_FILTER_INTERACTION_DEFER_MS;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function clearThreadFilterInteraction() {
|
|
687
|
+
state.threadFilterInteractionUntilMs = 0;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function shouldDeferRenderForActiveInteraction() {
|
|
678
691
|
const activeElement = document.activeElement;
|
|
679
|
-
if (
|
|
680
|
-
|
|
692
|
+
if (
|
|
693
|
+
activeElement instanceof HTMLTextAreaElement &&
|
|
694
|
+
activeElement.matches("[data-completion-reply-textarea]") &&
|
|
695
|
+
normalizeClientText(activeElement.dataset.replyToken) === normalizeClientText(state.currentItem?.token)
|
|
696
|
+
) {
|
|
697
|
+
return true;
|
|
681
698
|
}
|
|
682
|
-
if (
|
|
683
|
-
|
|
699
|
+
if (
|
|
700
|
+
activeElement instanceof HTMLSelectElement &&
|
|
701
|
+
activeElement.matches("[data-timeline-thread-select], [data-completed-thread-select]")
|
|
702
|
+
) {
|
|
703
|
+
return true;
|
|
684
704
|
}
|
|
685
|
-
return
|
|
705
|
+
return state.threadFilterInteractionUntilMs > Date.now();
|
|
686
706
|
}
|
|
687
707
|
|
|
688
708
|
function normalizeChoiceAnswersMap(value) {
|
|
@@ -748,11 +768,70 @@ function normalizeReplyMode(value) {
|
|
|
748
768
|
return normalizeClientText(value).toLowerCase() === "plan" ? "plan" : "default";
|
|
749
769
|
}
|
|
750
770
|
|
|
771
|
+
const COMPLETION_REPLY_IMAGE_SUPPORT = false;
|
|
772
|
+
|
|
773
|
+
function normalizeCompletionReplyAttachment(value) {
|
|
774
|
+
if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
if (!value || typeof value !== "object") {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
const file = typeof File !== "undefined" && value.file instanceof File ? value.file : null;
|
|
781
|
+
const name = normalizeClientText(value.name || file?.name || "");
|
|
782
|
+
const type = normalizeClientText(value.type || file?.type || "");
|
|
783
|
+
const size = Number(value.size ?? file?.size) || 0;
|
|
784
|
+
const previewUrl = normalizeClientText(value.previewUrl || "");
|
|
785
|
+
if (!file || !name || !type.startsWith("image/") || size <= 0) {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
file,
|
|
790
|
+
name,
|
|
791
|
+
type,
|
|
792
|
+
size,
|
|
793
|
+
previewUrl,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function createCompletionReplyAttachment(file) {
|
|
798
|
+
if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
if (!(typeof File !== "undefined" && file instanceof File)) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
if (!normalizeClientText(file.type).startsWith("image/")) {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
return normalizeCompletionReplyAttachment({
|
|
808
|
+
file,
|
|
809
|
+
name: file.name,
|
|
810
|
+
type: file.type,
|
|
811
|
+
size: file.size,
|
|
812
|
+
previewUrl: typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
|
|
813
|
+
? URL.createObjectURL(file)
|
|
814
|
+
: "",
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function releaseCompletionReplyAttachment(attachment) {
|
|
819
|
+
if (!attachment?.previewUrl || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
try {
|
|
823
|
+
URL.revokeObjectURL(attachment.previewUrl);
|
|
824
|
+
} catch {
|
|
825
|
+
// Ignore best-effort object URL cleanup errors.
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
751
829
|
function getCompletionReplyDraft(token) {
|
|
752
830
|
if (!token) {
|
|
753
831
|
return {
|
|
754
832
|
text: "",
|
|
755
833
|
sentText: "",
|
|
834
|
+
attachment: null,
|
|
756
835
|
mode: "default",
|
|
757
836
|
notice: "",
|
|
758
837
|
error: "",
|
|
@@ -767,6 +846,7 @@ function getCompletionReplyDraft(token) {
|
|
|
767
846
|
return {
|
|
768
847
|
text: String(draft.text ?? ""),
|
|
769
848
|
sentText: normalizeClientText(draft.sentText ?? ""),
|
|
849
|
+
attachment: normalizeCompletionReplyAttachment(draft.attachment),
|
|
770
850
|
mode: normalizeReplyMode(draft.mode),
|
|
771
851
|
notice: normalizeClientText(draft.notice),
|
|
772
852
|
error: normalizeClientText(draft.error),
|
|
@@ -798,13 +878,22 @@ function setCompletionReplyDraft(token, partialDraft) {
|
|
|
798
878
|
if (!token) {
|
|
799
879
|
return;
|
|
800
880
|
}
|
|
881
|
+
const previousStoredDraft = state.completionReplyDrafts?.[token] || {};
|
|
882
|
+
const previousAttachment = normalizeCompletionReplyAttachment(previousStoredDraft.attachment);
|
|
801
883
|
const nextDraft = {
|
|
802
884
|
...getCompletionReplyDraft(token),
|
|
803
885
|
...(partialDraft || {}),
|
|
804
886
|
};
|
|
887
|
+
const nextAttachment = Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachment")
|
|
888
|
+
? normalizeCompletionReplyAttachment(partialDraft?.attachment)
|
|
889
|
+
: normalizeCompletionReplyAttachment(nextDraft.attachment);
|
|
890
|
+
if (previousAttachment?.previewUrl && previousAttachment.previewUrl !== nextAttachment?.previewUrl) {
|
|
891
|
+
releaseCompletionReplyAttachment(previousAttachment);
|
|
892
|
+
}
|
|
805
893
|
state.completionReplyDrafts[token] = {
|
|
806
894
|
text: String(nextDraft.text ?? ""),
|
|
807
895
|
sentText: normalizeClientText(nextDraft.sentText ?? ""),
|
|
896
|
+
attachment: nextAttachment,
|
|
808
897
|
mode: normalizeReplyMode(nextDraft.mode),
|
|
809
898
|
notice: normalizeClientText(nextDraft.notice),
|
|
810
899
|
error: normalizeClientText(nextDraft.error),
|
|
@@ -819,9 +908,16 @@ function clearCompletionReplyDraft(token) {
|
|
|
819
908
|
if (!token || !state.completionReplyDrafts?.[token]) {
|
|
820
909
|
return;
|
|
821
910
|
}
|
|
911
|
+
releaseCompletionReplyAttachment(state.completionReplyDrafts[token]?.attachment);
|
|
822
912
|
delete state.completionReplyDrafts[token];
|
|
823
913
|
}
|
|
824
914
|
|
|
915
|
+
function clearAllCompletionReplyDrafts() {
|
|
916
|
+
for (const token of Object.keys(state.completionReplyDrafts || {})) {
|
|
917
|
+
clearCompletionReplyDraft(token);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
825
921
|
function syncCompletionReplyComposerLiveState(replyForm, draft) {
|
|
826
922
|
if (!replyForm) {
|
|
827
923
|
return;
|
|
@@ -2193,6 +2289,8 @@ function renderCompletionReplyComposer(detail, options = {}) {
|
|
|
2193
2289
|
const warningTimestamp = draft.warning?.createdAtMs ? formatTimelineTimestamp(draft.warning.createdAtMs) : "";
|
|
2194
2290
|
const showCollapsedState =
|
|
2195
2291
|
draft.collapsedAfterSend && Boolean(draft.notice) && !draft.error && !draft.warning && !draft.sending;
|
|
2292
|
+
const attachmentName = draft.attachment?.name ? escapeHtml(draft.attachment.name) : "";
|
|
2293
|
+
const attachmentPreviewUrl = draft.attachment?.previewUrl ? escapeHtml(draft.attachment.previewUrl) : "";
|
|
2196
2294
|
|
|
2197
2295
|
return `
|
|
2198
2296
|
<section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
|
|
@@ -2258,6 +2356,55 @@ function renderCompletionReplyComposer(detail, options = {}) {
|
|
|
2258
2356
|
data-reply-token="${escapeHtml(detail.token)}"
|
|
2259
2357
|
>${escapeHtml(draft.text)}</textarea>
|
|
2260
2358
|
</label>
|
|
2359
|
+
${
|
|
2360
|
+
detail.reply?.supportsImages
|
|
2361
|
+
? `
|
|
2362
|
+
<div class="reply-attachment-field">
|
|
2363
|
+
<div class="reply-attachment-field__header">
|
|
2364
|
+
<span class="field-label">${escapeHtml(L("reply.imageLabel"))}</span>
|
|
2365
|
+
${
|
|
2366
|
+
draft.attachment
|
|
2367
|
+
? `
|
|
2368
|
+
<button
|
|
2369
|
+
class="secondary secondary--compact"
|
|
2370
|
+
type="button"
|
|
2371
|
+
data-reply-image-remove
|
|
2372
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2373
|
+
>
|
|
2374
|
+
${escapeHtml(L("reply.imageRemove"))}
|
|
2375
|
+
</button>
|
|
2376
|
+
`
|
|
2377
|
+
: ""
|
|
2378
|
+
}
|
|
2379
|
+
</div>
|
|
2380
|
+
<label class="reply-attachment-picker">
|
|
2381
|
+
<input
|
|
2382
|
+
class="reply-attachment-picker__input"
|
|
2383
|
+
type="file"
|
|
2384
|
+
accept="image/*"
|
|
2385
|
+
data-reply-image-input
|
|
2386
|
+
data-reply-token="${escapeHtml(detail.token)}"
|
|
2387
|
+
>
|
|
2388
|
+
<span class="reply-attachment-picker__label">${escapeHtml(L(draft.attachment ? "reply.imageReplace" : "reply.imageAdd"))}</span>
|
|
2389
|
+
<span class="reply-attachment-picker__hint">${escapeHtml(L("reply.imageHint"))}</span>
|
|
2390
|
+
</label>
|
|
2391
|
+
${
|
|
2392
|
+
draft.attachment
|
|
2393
|
+
? `
|
|
2394
|
+
<div class="reply-image-preview">
|
|
2395
|
+
<img class="reply-image-preview__image" src="${attachmentPreviewUrl}" alt="${attachmentName}">
|
|
2396
|
+
<div class="reply-image-preview__copy">
|
|
2397
|
+
<p class="reply-image-preview__name">${attachmentName}</p>
|
|
2398
|
+
<p class="reply-image-preview__meta">${escapeHtml(L("reply.imageAttached"))}</p>
|
|
2399
|
+
</div>
|
|
2400
|
+
</div>
|
|
2401
|
+
`
|
|
2402
|
+
: ""
|
|
2403
|
+
}
|
|
2404
|
+
</div>
|
|
2405
|
+
`
|
|
2406
|
+
: ""
|
|
2407
|
+
}
|
|
2261
2408
|
${
|
|
2262
2409
|
detail.reply?.supportsPlanMode
|
|
2263
2410
|
? `
|
|
@@ -2712,7 +2859,18 @@ function bindShellInteractions() {
|
|
|
2712
2859
|
}
|
|
2713
2860
|
|
|
2714
2861
|
for (const select of document.querySelectorAll("[data-timeline-thread-select]")) {
|
|
2862
|
+
const handleInteractionStart = () => {
|
|
2863
|
+
markThreadFilterInteraction();
|
|
2864
|
+
};
|
|
2865
|
+
const handleInteractionEnd = () => {
|
|
2866
|
+
clearThreadFilterInteraction();
|
|
2867
|
+
};
|
|
2868
|
+
select.addEventListener("pointerdown", handleInteractionStart);
|
|
2869
|
+
select.addEventListener("click", handleInteractionStart);
|
|
2870
|
+
select.addEventListener("focus", handleInteractionStart);
|
|
2871
|
+
select.addEventListener("blur", handleInteractionEnd);
|
|
2715
2872
|
select.addEventListener("change", async () => {
|
|
2873
|
+
clearThreadFilterInteraction();
|
|
2716
2874
|
state.timelineThreadFilter = select.value || "all";
|
|
2717
2875
|
alignCurrentItemToVisibleEntries();
|
|
2718
2876
|
await renderShell();
|
|
@@ -2720,7 +2878,18 @@ function bindShellInteractions() {
|
|
|
2720
2878
|
}
|
|
2721
2879
|
|
|
2722
2880
|
for (const select of document.querySelectorAll("[data-completed-thread-select]")) {
|
|
2881
|
+
const handleInteractionStart = () => {
|
|
2882
|
+
markThreadFilterInteraction();
|
|
2883
|
+
};
|
|
2884
|
+
const handleInteractionEnd = () => {
|
|
2885
|
+
clearThreadFilterInteraction();
|
|
2886
|
+
};
|
|
2887
|
+
select.addEventListener("pointerdown", handleInteractionStart);
|
|
2888
|
+
select.addEventListener("click", handleInteractionStart);
|
|
2889
|
+
select.addEventListener("focus", handleInteractionStart);
|
|
2890
|
+
select.addEventListener("blur", handleInteractionEnd);
|
|
2723
2891
|
select.addEventListener("change", async () => {
|
|
2892
|
+
clearThreadFilterInteraction();
|
|
2724
2893
|
state.completedThreadFilter = select.value || "all";
|
|
2725
2894
|
alignCurrentItemToVisibleEntries();
|
|
2726
2895
|
await renderShell();
|
|
@@ -2802,6 +2971,46 @@ function bindShellInteractions() {
|
|
|
2802
2971
|
});
|
|
2803
2972
|
}
|
|
2804
2973
|
|
|
2974
|
+
for (const input of document.querySelectorAll("[data-reply-image-input][data-reply-token]")) {
|
|
2975
|
+
input.addEventListener("change", async () => {
|
|
2976
|
+
const token = input.dataset.replyToken || "";
|
|
2977
|
+
const [file] = Array.from(input.files || []);
|
|
2978
|
+
const nextAttachment = createCompletionReplyAttachment(file);
|
|
2979
|
+
if (!nextAttachment && file) {
|
|
2980
|
+
setCompletionReplyDraft(token, {
|
|
2981
|
+
error: L("error.completionReplyImageInvalidType"),
|
|
2982
|
+
notice: "",
|
|
2983
|
+
warning: null,
|
|
2984
|
+
confirmOverride: false,
|
|
2985
|
+
});
|
|
2986
|
+
await renderShell();
|
|
2987
|
+
return;
|
|
2988
|
+
}
|
|
2989
|
+
setCompletionReplyDraft(token, {
|
|
2990
|
+
attachment: nextAttachment,
|
|
2991
|
+
notice: "",
|
|
2992
|
+
error: "",
|
|
2993
|
+
warning: null,
|
|
2994
|
+
confirmOverride: false,
|
|
2995
|
+
});
|
|
2996
|
+
await renderShell();
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
for (const button of document.querySelectorAll("[data-reply-image-remove][data-reply-token]")) {
|
|
3001
|
+
button.addEventListener("click", async () => {
|
|
3002
|
+
const token = button.dataset.replyToken || "";
|
|
3003
|
+
setCompletionReplyDraft(token, {
|
|
3004
|
+
attachment: null,
|
|
3005
|
+
notice: "",
|
|
3006
|
+
error: "",
|
|
3007
|
+
warning: null,
|
|
3008
|
+
confirmOverride: false,
|
|
3009
|
+
});
|
|
3010
|
+
await renderShell();
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
|
|
2805
3014
|
for (const button of document.querySelectorAll("[data-open-logout-confirm]")) {
|
|
2806
3015
|
button.addEventListener("click", async () => {
|
|
2807
3016
|
state.logoutConfirmOpen = true;
|
|
@@ -2971,14 +3180,18 @@ function bindShellInteractions() {
|
|
|
2971
3180
|
await renderShell();
|
|
2972
3181
|
|
|
2973
3182
|
try {
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
3183
|
+
const requestBody = new FormData();
|
|
3184
|
+
requestBody.set("text", text);
|
|
3185
|
+
requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
|
|
3186
|
+
requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
|
|
3187
|
+
if (COMPLETION_REPLY_IMAGE_SUPPORT && draft.attachment?.file) {
|
|
3188
|
+
requestBody.append("image", draft.attachment.file, draft.attachment.name || draft.attachment.file.name);
|
|
3189
|
+
}
|
|
3190
|
+
await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, requestBody);
|
|
2979
3191
|
setCompletionReplyDraft(token, {
|
|
2980
3192
|
text: "",
|
|
2981
3193
|
sentText: text,
|
|
3194
|
+
attachment: null,
|
|
2982
3195
|
mode: draft.mode,
|
|
2983
3196
|
sending: false,
|
|
2984
3197
|
error: "",
|
|
@@ -2993,6 +3206,7 @@ function bindShellInteractions() {
|
|
|
2993
3206
|
setCompletionReplyDraft(token, {
|
|
2994
3207
|
text,
|
|
2995
3208
|
sentText: "",
|
|
3209
|
+
attachment: draft.attachment,
|
|
2996
3210
|
mode: draft.mode,
|
|
2997
3211
|
sending: false,
|
|
2998
3212
|
notice: "",
|
|
@@ -3007,6 +3221,7 @@ function bindShellInteractions() {
|
|
|
3007
3221
|
setCompletionReplyDraft(token, {
|
|
3008
3222
|
text,
|
|
3009
3223
|
sentText: "",
|
|
3224
|
+
attachment: draft.attachment,
|
|
3010
3225
|
mode: draft.mode,
|
|
3011
3226
|
sending: false,
|
|
3012
3227
|
notice: "",
|
|
@@ -3720,14 +3935,19 @@ async function apiGet(url) {
|
|
|
3720
3935
|
}
|
|
3721
3936
|
|
|
3722
3937
|
async function apiPost(url, body) {
|
|
3938
|
+
const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
|
|
3723
3939
|
const response = await fetch(url, {
|
|
3724
3940
|
method: "POST",
|
|
3725
3941
|
credentials: "same-origin",
|
|
3726
|
-
headers:
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3942
|
+
headers: isFormDataBody
|
|
3943
|
+
? {
|
|
3944
|
+
Accept: "application/json",
|
|
3945
|
+
}
|
|
3946
|
+
: {
|
|
3947
|
+
"Content-Type": "application/json",
|
|
3948
|
+
Accept: "application/json",
|
|
3949
|
+
},
|
|
3950
|
+
body: isFormDataBody ? body : JSON.stringify(body || {}),
|
|
3731
3951
|
});
|
|
3732
3952
|
if (!response.ok) {
|
|
3733
3953
|
const errorInfo = await readError(response);
|
|
@@ -3770,6 +3990,11 @@ function localizeApiError(value) {
|
|
|
3770
3990
|
"completion-reply-unavailable": "error.completionReplyUnavailable",
|
|
3771
3991
|
"completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",
|
|
3772
3992
|
"completion-reply-empty": "error.completionReplyEmpty",
|
|
3993
|
+
"completion-reply-image-invalid-type": "error.completionReplyImageInvalidType",
|
|
3994
|
+
"completion-reply-image-too-large": "error.completionReplyImageTooLarge",
|
|
3995
|
+
"completion-reply-image-limit": "error.completionReplyImageLimit",
|
|
3996
|
+
"completion-reply-image-invalid-upload": "error.completionReplyImageInvalidUpload",
|
|
3997
|
+
"completion-reply-image-disabled": "error.completionReplyImageDisabled",
|
|
3773
3998
|
"codex-ipc-not-connected": "error.codexIpcNotConnected",
|
|
3774
3999
|
"approval-not-found": "error.approvalNotFound",
|
|
3775
4000
|
"approval-already-handled": "error.approvalAlreadyHandled",
|
|
@@ -3850,6 +4075,19 @@ function desiredBootstrapPairingToken() {
|
|
|
3850
4075
|
return initialPairToken;
|
|
3851
4076
|
}
|
|
3852
4077
|
|
|
4078
|
+
function shouldAutoPairFromBootstrapToken() {
|
|
4079
|
+
if (!initialPairToken) {
|
|
4080
|
+
return false;
|
|
4081
|
+
}
|
|
4082
|
+
if (isStandaloneMode()) {
|
|
4083
|
+
return true;
|
|
4084
|
+
}
|
|
4085
|
+
if (isProbablySafari()) {
|
|
4086
|
+
return false;
|
|
4087
|
+
}
|
|
4088
|
+
return true;
|
|
4089
|
+
}
|
|
4090
|
+
|
|
3853
4091
|
function urlBase64ToUint8Array(base64String) {
|
|
3854
4092
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
3855
4093
|
const normalized = `${base64String}${padding}`.replace(/-/gu, "+").replace(/_/gu, "/");
|
package/web/i18n.js
CHANGED
|
@@ -70,6 +70,8 @@ const translations = {
|
|
|
70
70
|
"pair.codeLabel": "Pairing code",
|
|
71
71
|
"pair.codePlaceholder": "Enter the pairing code",
|
|
72
72
|
"pair.connect": "Connect this iPhone",
|
|
73
|
+
"pair.installFromHomeScreen":
|
|
74
|
+
"This pairing link is being kept for the Home Screen app. Add viveworker to your Home Screen from Safari, then open the new icon to finish pairing there.",
|
|
73
75
|
"pair.helperTitle": "Add to Home Screen",
|
|
74
76
|
"pair.helperCopy": "Install viveworker for the best mobile layout and Web Push support.",
|
|
75
77
|
"banner.install.title": "Add viveworker to your Home Screen",
|
|
@@ -112,6 +114,12 @@ const translations = {
|
|
|
112
114
|
"reply.copy": "Send a new message back into this Codex thread from the latest completed result.",
|
|
113
115
|
"reply.fieldLabel": "Message",
|
|
114
116
|
"reply.placeholder": "Ask Codex to continue, refine the result, or try a different approach.",
|
|
117
|
+
"reply.imageLabel": "Image",
|
|
118
|
+
"reply.imageAdd": "Add image",
|
|
119
|
+
"reply.imageReplace": "Replace image",
|
|
120
|
+
"reply.imageRemove": "Remove",
|
|
121
|
+
"reply.imageHint": "Attach one image from your iPhone and send it together with your message.",
|
|
122
|
+
"reply.imageAttached": "Attached image",
|
|
115
123
|
"reply.send": "Send to Codex",
|
|
116
124
|
"reply.sendConfirm": "Send anyway",
|
|
117
125
|
"reply.sendAnother": "Send another message",
|
|
@@ -283,6 +291,11 @@ const translations = {
|
|
|
283
291
|
"error.completionReplyUnavailable": "This completed item can no longer send a follow-up.",
|
|
284
292
|
"error.completionReplyThreadAdvanced": "This thread already has newer messages.",
|
|
285
293
|
"error.completionReplyEmpty": "Enter a message before sending it.",
|
|
294
|
+
"error.completionReplyImageInvalidType": "Choose an image file to attach.",
|
|
295
|
+
"error.completionReplyImageTooLarge": "That image is too large to send from this reply screen.",
|
|
296
|
+
"error.completionReplyImageLimit": "Attach one image at a time for now.",
|
|
297
|
+
"error.completionReplyImageInvalidUpload": "That image could not be uploaded.",
|
|
298
|
+
"error.completionReplyImageDisabled": "Image attachments are not available in this release.",
|
|
286
299
|
"error.codexIpcNotConnected": "Codex desktop is not connected right now.",
|
|
287
300
|
"error.approvalNotFound": "This approval is no longer available.",
|
|
288
301
|
"error.approvalAlreadyHandled": "This approval was already handled.",
|
|
@@ -394,6 +407,8 @@ const translations = {
|
|
|
394
407
|
"logout.action.removeDevice": "Log out and remove this device",
|
|
395
408
|
"cli.setup.warning.insecureHttpLan":
|
|
396
409
|
"Warning: insecure LAN HTTP is enabled. Session and device cookies can be exposed on the local network.",
|
|
410
|
+
"cli.setup.warning.stalePairingServer":
|
|
411
|
+
"Warning: viveworker is responding on port {port}, but it is still serving older pairing credentials. Another bridge process may still own this port. Stop the older instance, then rerun setup or start.",
|
|
397
412
|
"cli.setup.qrPairing": "Pairing QR:",
|
|
398
413
|
"cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
|
|
399
414
|
"cli.setup.prompt.continueToApp": "Press Enter to continue to the viveworker app URL.",
|
|
@@ -509,6 +524,8 @@ const translations = {
|
|
|
509
524
|
"pair.codeLabel": "ペアリングコード",
|
|
510
525
|
"pair.codePlaceholder": "ペアリングコードを入力",
|
|
511
526
|
"pair.connect": "この iPhone を接続",
|
|
527
|
+
"pair.installFromHomeScreen":
|
|
528
|
+
"この pairing link はホーム画面アプリ用に温存されています。Safari から viveworker をホーム画面に追加し、新しいアイコンを開いてそこで pairing を完了してください。",
|
|
512
529
|
"pair.helperTitle": "ホーム画面に追加",
|
|
513
530
|
"pair.helperCopy": "モバイル表示と Web Push を最適に使うには viveworker をインストールしてください。",
|
|
514
531
|
"banner.install.title": "viveworker をホーム画面に追加",
|
|
@@ -550,6 +567,12 @@ const translations = {
|
|
|
550
567
|
"reply.copy": "この完了結果を見ながら、同じ Codex スレッドに新しいメッセージを送れます。",
|
|
551
568
|
"reply.fieldLabel": "メッセージ",
|
|
552
569
|
"reply.placeholder": "続きを依頼したり、結果の修正や別案を Codex に伝えます。",
|
|
570
|
+
"reply.imageLabel": "画像",
|
|
571
|
+
"reply.imageAdd": "画像を追加",
|
|
572
|
+
"reply.imageReplace": "画像を差し替え",
|
|
573
|
+
"reply.imageRemove": "削除",
|
|
574
|
+
"reply.imageHint": "iPhone の画像を 1 枚だけ添付して、メッセージと一緒に送れます。",
|
|
575
|
+
"reply.imageAttached": "添付した画像",
|
|
553
576
|
"reply.send": "Codex に送信",
|
|
554
577
|
"reply.sendConfirm": "それでも送信",
|
|
555
578
|
"reply.sendAnother": "もう一度送る",
|
|
@@ -721,6 +744,11 @@ const translations = {
|
|
|
721
744
|
"error.completionReplyUnavailable": "この完了項目には、もう返信できません。",
|
|
722
745
|
"error.completionReplyThreadAdvanced": "このスレッドには、すでに新しいメッセージがあります。",
|
|
723
746
|
"error.completionReplyEmpty": "送信前にメッセージを入力してください。",
|
|
747
|
+
"error.completionReplyImageInvalidType": "添付できるのは画像ファイルのみです。",
|
|
748
|
+
"error.completionReplyImageTooLarge": "この画面から送るには画像が大きすぎます。",
|
|
749
|
+
"error.completionReplyImageLimit": "いまは画像を 1 枚ずつ添付してください。",
|
|
750
|
+
"error.completionReplyImageInvalidUpload": "この画像はアップロードできませんでした。",
|
|
751
|
+
"error.completionReplyImageDisabled": "このリリースでは画像添付はまだ利用できません。",
|
|
724
752
|
"error.codexIpcNotConnected": "いまは Codex desktop に接続できていません。",
|
|
725
753
|
"error.approvalNotFound": "この承認はもう利用できません。",
|
|
726
754
|
"error.approvalAlreadyHandled": "この承認はすでに処理済みです。",
|
|
@@ -832,6 +860,8 @@ const translations = {
|
|
|
832
860
|
"logout.action.removeDevice": "ログアウトしてこの端末を削除",
|
|
833
861
|
"cli.setup.warning.insecureHttpLan":
|
|
834
862
|
"警告: insecure LAN HTTP が有効です。session / device cookie がローカルネットワーク上で露出する可能性があります。",
|
|
863
|
+
"cli.setup.warning.stalePairingServer":
|
|
864
|
+
"警告: port {port} の viveworker は応答していますが、古い pairing 情報を配信したままです。別の bridge process がこの port を使っている可能性があります。古い instance を停止してから setup または start をやり直してください。",
|
|
835
865
|
"cli.setup.qrPairing": "Pairing QR:",
|
|
836
866
|
"cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
|
|
837
867
|
"cli.setup.prompt.continueToApp": "viveworker の接続先 URL を表示するには Enter を押してください。",
|
package/web/sw.js
CHANGED