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 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
+ ![viveworker social preview](./assets/app-screenshot.png)
2
+
1
3
  # viveworker
2
4
 
5
+ [![npm version](https://badge.fury.io/js/viveworker.svg)](https://badge.fury.io/js/viveworker)
6
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](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.0",
4
- "description": "A local iPhone companion for Codex Desktop approvals, plans, questions, and completions.",
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
- async function handleCompletionReply({ runtime, completionItem, text, planMode = false, force = false }) {
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 payload = await parseJsonBody(req);
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: resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json")),
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
- if (!process.env[key]) {
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, "")
@@ -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
- progress.done(healthy ? "cli.setup.complete" : "cli.setup.completePending");
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
- progress.done(healthy ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
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
- progress.done(healthy ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
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 (!shouldDeferRenderForActiveReplyComposer()) {
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 shouldDeferRenderForActiveReplyComposer() {
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 (!(activeElement instanceof HTMLTextAreaElement)) {
680
- return false;
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 (!activeElement.matches("[data-completion-reply-textarea]")) {
683
- return false;
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 normalizeClientText(activeElement.dataset.replyToken) === normalizeClientText(state.currentItem?.token);
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
- await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, {
2975
- text,
2976
- planMode: draft.mode === "plan",
2977
- force: draft.confirmOverride === true,
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
- "Content-Type": "application/json",
3728
- Accept: "application/json",
3729
- },
3730
- body: JSON.stringify(body || {}),
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
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "viveworker-v2";
1
+ const CACHE_NAME = "viveworker-v4";
2
2
  const APP_ASSETS = ["/app.css", "/app.js", "/i18n.js"];
3
3
  const APP_ROUTES = new Set(["/", "/app", "/app/"]);
4
4
  const CACHED_PATHS = new Set(APP_ASSETS);