clay-server 3.7.0-beta.2 → 3.8.0-beta.1

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/lib/os-users.js CHANGED
@@ -98,7 +98,7 @@ function resolveOsUserInfo(username) {
98
98
 
99
99
  /**
100
100
  * Run a file system operation as a specific OS user via a helper subprocess.
101
- * op: "list", "read", "write", "stat"
101
+ * op: "list", "read", "write", "read_binary", "mkdir", "copy", "stat"
102
102
  * args: operation-specific arguments
103
103
  * osUserInfo: { uid, gid } from resolveOsUserInfo
104
104
  * Returns the result object or throws.
@@ -164,6 +164,22 @@ function fsAsUser(op, args, osUserInfo) {
164
164
  "fs.writeFileSync(f, content, 'utf8');",
165
165
  "process.stdout.write(JSON.stringify({ ok: true }));",
166
166
  ].join(" ");
167
+ } else if (op === "mkdir") {
168
+ script = [
169
+ "var fs = require('fs');",
170
+ "var dir = " + JSON.stringify(args.dir) + ";",
171
+ "fs.mkdirSync(dir, { recursive: true });",
172
+ "process.stdout.write(JSON.stringify({ ok: true }));",
173
+ ].join(" ");
174
+ } else if (op === "copy") {
175
+ script = [
176
+ "var fs = require('fs');",
177
+ "var source = " + JSON.stringify(args.source) + ";",
178
+ "var file = " + JSON.stringify(args.file) + ";",
179
+ "var overwrite = " + JSON.stringify(!!args.overwrite) + ";",
180
+ "fs.copyFileSync(source, file, overwrite ? 0 : fs.constants.COPYFILE_EXCL);",
181
+ "process.stdout.write(JSON.stringify({ ok: true }));",
182
+ ].join(" ");
167
183
  } else {
168
184
  throw new Error("Unknown fsAsUser operation: " + op);
169
185
  }
@@ -41,7 +41,7 @@ function parseJsonBody(req) {
41
41
  * ctx fields:
42
42
  * cwd, slug, project, sm, send, sendTo, imagesDir, osUsers, pushModule,
43
43
  * safePath, safeAbsPath, getOsUserInfoForReq, sendExtensionCommandAny,
44
- * _extToken, _browserTabList
44
+ * saveGeneratedImageToProject, _extToken, _browserTabList
45
45
  */
46
46
  function attachHTTP(ctx) {
47
47
  var cwd = ctx.cwd;
@@ -51,6 +51,7 @@ function attachHTTP(ctx) {
51
51
  var gitAttribution = ctx.gitAttribution;
52
52
  var send = ctx.send;
53
53
  var imagesDir = ctx.imagesDir;
54
+ var saveGeneratedImageToProject = ctx.saveGeneratedImageToProject;
54
55
  var osUsers = ctx.osUsers;
55
56
  var pushModule = ctx.pushModule;
56
57
  var safePath = ctx.safePath;
@@ -122,6 +123,38 @@ function attachHTTP(ctx) {
122
123
  return true;
123
124
  }
124
125
 
126
+ // Copy a generated image from Clay's session storage into the project.
127
+ if (req.method === "POST" && urlPath === "/api/generated-image/save") {
128
+ if (usersModule.isMultiUser()) {
129
+ var imageSaveUser = req._clayUser;
130
+ var imageSavePermissions = imageSaveUser ? usersModule.getEffectivePermissions(imageSaveUser, osUsers) : null;
131
+ if (!imageSavePermissions || !imageSavePermissions.fileBrowser) {
132
+ res.writeHead(403, { "Content-Type": "application/json" });
133
+ res.end('{"error":"File browser access is not permitted"}');
134
+ return true;
135
+ }
136
+ }
137
+ parseJsonBody(req).then(function (body) {
138
+ if (typeof saveGeneratedImageToProject !== "function") {
139
+ res.writeHead(501, { "Content-Type": "application/json" });
140
+ res.end('{"error":"Generated image saving is not available"}');
141
+ return;
142
+ }
143
+ var result = saveGeneratedImageToProject(
144
+ body.fileName,
145
+ body.path,
146
+ !!body.overwrite,
147
+ getOsUserInfoForReq(req)
148
+ );
149
+ res.writeHead(result.ok ? 200 : (result.status || 500), { "Content-Type": "application/json" });
150
+ res.end(JSON.stringify(result));
151
+ }).catch(function () {
152
+ res.writeHead(400, { "Content-Type": "application/json" });
153
+ res.end('{"error":"Invalid JSON body"}');
154
+ });
155
+ return true;
156
+ }
157
+
125
158
  // File upload
126
159
  if (req.method === "POST" && urlPath === "/api/upload") {
127
160
  parseJsonBody(req).then(function (body) {
@@ -12,13 +12,98 @@ var execFileSync = require("child_process").execFileSync;
12
12
  function attachImage(ctx) {
13
13
  var cwd = ctx.cwd;
14
14
  var slug = ctx.slug;
15
+ var fsAsUser = ctx.fsAsUser;
15
16
 
16
17
  // --- Chat image storage ---
17
18
  var _imgConfig = require("./config");
18
19
  var _imgUtils = require("./utils");
19
20
  var _imagesBaseDir = path.join(_imgConfig.CONFIG_DIR, "images");
20
21
  var _imagesEncodedCwd = _imgUtils.encodeCwd(cwd);
21
- var imagesDir = path.join(_imagesBaseDir, _imagesEncodedCwd);
22
+ var imagesDir = ctx.imagesDir || path.join(_imagesBaseDir, _imagesEncodedCwd);
23
+
24
+ function isWithin(base, candidate) {
25
+ return candidate === base || candidate.indexOf(base + path.sep) === 0;
26
+ }
27
+
28
+ function saveGeneratedImageToProject(fileName, requestedPath, overwrite, osUserInfo) {
29
+ if (typeof fileName !== "string" || !/^\d+-[a-f0-9]+\.\w+$/.test(fileName)) {
30
+ return { ok: false, status: 400, error: "Invalid generated image" };
31
+ }
32
+ if (typeof requestedPath !== "string" || !requestedPath.trim() || requestedPath.length > 500) {
33
+ return { ok: false, status: 400, error: "Enter a project path" };
34
+ }
35
+
36
+ var sourcePath = path.join(imagesDir, fileName);
37
+ var sourceExt = path.extname(fileName).toLowerCase();
38
+ var targetPath = requestedPath.trim();
39
+ if (path.isAbsolute(targetPath)) {
40
+ return { ok: false, status: 400, error: "Use a project-relative path" };
41
+ }
42
+ var targetExt = path.extname(targetPath).toLowerCase();
43
+ if (targetExt !== sourceExt) {
44
+ return { ok: false, status: 400, error: "Keep the " + sourceExt + " file extension" };
45
+ }
46
+
47
+ var projectRoot;
48
+ var targetAbs;
49
+ try {
50
+ projectRoot = fs.realpathSync(cwd);
51
+ targetAbs = path.resolve(projectRoot, targetPath);
52
+ } catch (e) {
53
+ return { ok: false, status: 400, error: "Project path is not available" };
54
+ }
55
+ if (targetAbs === projectRoot || !isWithin(projectRoot, targetAbs)) {
56
+ return { ok: false, status: 403, error: "Path must stay inside the project" };
57
+ }
58
+
59
+ try {
60
+ var sourceReal = fs.realpathSync(sourcePath);
61
+ var imagesRoot = fs.realpathSync(imagesDir);
62
+ if (!isWithin(imagesRoot, sourceReal) || !fs.statSync(sourceReal).isFile()) {
63
+ return { ok: false, status: 404, error: "Generated image not found" };
64
+ }
65
+
66
+ var parentDir = path.dirname(targetAbs);
67
+ var existingParent = parentDir;
68
+ while (!fs.existsSync(existingParent) && existingParent !== projectRoot) {
69
+ existingParent = path.dirname(existingParent);
70
+ }
71
+ var realExistingParent = fs.realpathSync(existingParent);
72
+ if (!isWithin(projectRoot, realExistingParent)) {
73
+ return { ok: false, status: 403, error: "Path must stay inside the project" };
74
+ }
75
+ if (osUserInfo && typeof fsAsUser === "function") {
76
+ fsAsUser("mkdir", { dir: parentDir }, osUserInfo);
77
+ } else {
78
+ fs.mkdirSync(parentDir, { recursive: true });
79
+ }
80
+ var realParent = fs.realpathSync(parentDir);
81
+ if (!isWithin(projectRoot, realParent)) {
82
+ return { ok: false, status: 403, error: "Path must stay inside the project" };
83
+ }
84
+ if (fs.existsSync(targetAbs)) {
85
+ if (fs.lstatSync(targetAbs).isSymbolicLink()) {
86
+ return { ok: false, status: 403, error: "Symbolic link targets cannot be replaced" };
87
+ }
88
+ if (!overwrite) {
89
+ return { ok: false, status: 409, error: "A file already exists at this path" };
90
+ }
91
+ }
92
+
93
+ if (osUserInfo && typeof fsAsUser === "function") {
94
+ fsAsUser("copy", { source: sourceReal, file: targetAbs, overwrite: !!overwrite }, osUserInfo);
95
+ } else {
96
+ fs.copyFileSync(sourceReal, targetAbs, overwrite ? 0 : fs.constants.COPYFILE_EXCL);
97
+ }
98
+ try { fs.chmodSync(targetAbs, 0o644); } catch (e2) {}
99
+ return { ok: true, path: path.relative(projectRoot, targetAbs).split(path.sep).join("/") };
100
+ } catch (e) {
101
+ if (e && e.code === "EEXIST") {
102
+ return { ok: false, status: 409, error: "A file already exists at this path" };
103
+ }
104
+ return { ok: false, status: 500, error: "Could not save the generated image" };
105
+ }
106
+ }
22
107
 
23
108
  // Convert imageRefs in history entries to images with URLs for the client
24
109
  function hydrateImageRefs(entry) {
@@ -89,6 +174,7 @@ function attachImage(ctx) {
89
174
  imagesDir: imagesDir,
90
175
  hydrateImageRefs: hydrateImageRefs,
91
176
  saveImageFile: saveImageFile,
177
+ saveGeneratedImageToProject: saveGeneratedImageToProject,
92
178
  };
93
179
  }
94
180
 
package/lib/project.js CHANGED
@@ -187,7 +187,7 @@ function createProjectContext(opts) {
187
187
  // Do NOT write to .claude-local/settings.json -- the SDK reads that too, causing duplicate spawns.
188
188
 
189
189
  // --- Image engine (delegated to project-image.js) ---
190
- var _image = attachImage({ cwd: cwd, slug: slug });
190
+ var _image = attachImage({ cwd: cwd, slug: slug, fsAsUser: fsAsUser });
191
191
  var imagesDir = _image.imagesDir;
192
192
  var hydrateImageRefs = _image.hydrateImageRefs;
193
193
  var saveImageFile = _image.saveImageFile;
@@ -1632,6 +1632,7 @@ function createProjectContext(opts) {
1632
1632
  gitAttribution: _gitAttribution,
1633
1633
  send: send,
1634
1634
  imagesDir: imagesDir,
1635
+ saveGeneratedImageToProject: _image.saveGeneratedImageToProject,
1635
1636
  osUsers: osUsers,
1636
1637
  pushModule: pushModule,
1637
1638
  safePath: safePath,
@@ -168,12 +168,27 @@
168
168
  }
169
169
 
170
170
  .generated-image-prompt {
171
+ display: block;
172
+ width: 100%;
173
+ padding: 0;
174
+ border: 0;
171
175
  overflow: hidden;
176
+ background: transparent;
172
177
  color: var(--text-muted);
178
+ font: inherit;
173
179
  font-size: 11px;
174
180
  line-height: 1.35;
181
+ text-align: left;
175
182
  text-overflow: ellipsis;
176
183
  white-space: nowrap;
184
+ cursor: pointer;
185
+ }
186
+
187
+ .generated-image-prompt:hover {
188
+ color: var(--text-secondary);
189
+ text-decoration: underline;
190
+ text-decoration-color: var(--border);
191
+ text-underline-offset: 3px;
177
192
  }
178
193
 
179
194
  .generated-image-actions {
@@ -209,6 +224,213 @@
209
224
  height: 14px;
210
225
  }
211
226
 
227
+ .generated-image-details-backdrop {
228
+ position: fixed;
229
+ inset: 0;
230
+ z-index: 10050;
231
+ display: grid;
232
+ place-items: center;
233
+ padding: 24px;
234
+ background: rgba(0, 0, 0, 0.58);
235
+ backdrop-filter: blur(5px);
236
+ }
237
+
238
+ .generated-image-details {
239
+ display: flex;
240
+ width: min(720px, 100%);
241
+ max-height: min(820px, calc(100dvh - 48px));
242
+ overflow: hidden;
243
+ flex-direction: column;
244
+ border: 1px solid var(--border);
245
+ border-radius: 16px;
246
+ background: var(--bg);
247
+ box-shadow: 0 24px 80px rgba(var(--shadow-rgb), 0.42);
248
+ }
249
+
250
+ .generated-image-details-header {
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: space-between;
254
+ padding: 17px 18px 15px 20px;
255
+ border-bottom: 1px solid var(--border-subtle);
256
+ }
257
+
258
+ .generated-image-details-header h2 {
259
+ margin: 2px 0 0;
260
+ color: var(--text);
261
+ font-size: 17px;
262
+ line-height: 1.2;
263
+ }
264
+
265
+ .generated-image-details-eyebrow {
266
+ display: block;
267
+ color: var(--accent);
268
+ font-size: 9px;
269
+ font-weight: 700;
270
+ letter-spacing: 0.12em;
271
+ }
272
+
273
+ .generated-image-details-close {
274
+ width: 32px;
275
+ padding: 0;
276
+ justify-content: center;
277
+ }
278
+
279
+ .generated-image-details-close span { display: none; }
280
+
281
+ .generated-image-details-body {
282
+ display: grid;
283
+ grid-template-columns: 190px minmax(0, 1fr);
284
+ gap: 18px;
285
+ padding: 20px;
286
+ overflow-y: auto;
287
+ }
288
+
289
+ .generated-image-details-preview {
290
+ grid-row: span 2;
291
+ overflow: hidden;
292
+ align-self: start;
293
+ border: 1px solid var(--border-subtle);
294
+ border-radius: 11px;
295
+ background: var(--bg-alt);
296
+ }
297
+
298
+ .generated-image-details-preview img {
299
+ display: block;
300
+ width: 100%;
301
+ max-height: 280px;
302
+ object-fit: contain;
303
+ cursor: zoom-in;
304
+ }
305
+
306
+ .generated-image-details-section {
307
+ min-width: 0;
308
+ }
309
+
310
+ .generated-image-details-section-header {
311
+ display: flex;
312
+ min-height: 28px;
313
+ align-items: center;
314
+ justify-content: space-between;
315
+ gap: 10px;
316
+ margin-bottom: 7px;
317
+ }
318
+
319
+ .generated-image-details-section h3,
320
+ .generated-image-save-section label {
321
+ margin: 0;
322
+ color: var(--text-secondary);
323
+ font-size: 11px;
324
+ font-weight: 650;
325
+ letter-spacing: 0.02em;
326
+ }
327
+
328
+ .generated-image-details-section-header .generated-image-action {
329
+ height: 27px;
330
+ padding: 0 7px;
331
+ }
332
+
333
+ .generated-image-details-prompt {
334
+ max-height: 220px;
335
+ padding: 11px 12px;
336
+ overflow-y: auto;
337
+ border: 1px solid var(--border-subtle);
338
+ border-radius: 9px;
339
+ background: var(--bg-alt);
340
+ color: var(--text-secondary);
341
+ font-size: 12px;
342
+ line-height: 1.55;
343
+ white-space: pre-wrap;
344
+ overflow-wrap: anywhere;
345
+ }
346
+
347
+ .generated-image-save-section {
348
+ display: flex;
349
+ flex-direction: column;
350
+ gap: 7px;
351
+ }
352
+
353
+ .generated-image-save-section input {
354
+ width: 100%;
355
+ height: 38px;
356
+ padding: 0 11px;
357
+ border: 1px solid var(--border);
358
+ border-radius: 9px;
359
+ outline: none;
360
+ background: var(--input-bg);
361
+ color: var(--text);
362
+ font-family: var(--font-mono);
363
+ font-size: 12px;
364
+ }
365
+
366
+ .generated-image-save-section input:focus {
367
+ border-color: var(--accent);
368
+ box-shadow: 0 0 0 3px var(--accent-12);
369
+ }
370
+
371
+ .generated-image-save-hint,
372
+ .generated-image-save-error {
373
+ margin: 0;
374
+ font-size: 10px;
375
+ line-height: 1.4;
376
+ }
377
+
378
+ .generated-image-save-hint { color: var(--text-dimmer); }
379
+ .generated-image-save-error { color: var(--error); }
380
+ .generated-image-save-error.hidden { display: none; }
381
+
382
+ .generated-image-details-footer {
383
+ display: flex;
384
+ justify-content: flex-end;
385
+ gap: 8px;
386
+ padding: 13px 18px;
387
+ border-top: 1px solid var(--border-subtle);
388
+ background: var(--bg-alt);
389
+ }
390
+
391
+ .generated-image-secondary-button,
392
+ .generated-image-primary-button {
393
+ display: inline-flex;
394
+ height: 36px;
395
+ align-items: center;
396
+ justify-content: center;
397
+ gap: 7px;
398
+ padding: 0 14px;
399
+ border: 1px solid var(--border);
400
+ border-radius: 9px;
401
+ font: inherit;
402
+ font-size: 12px;
403
+ font-weight: 600;
404
+ cursor: pointer;
405
+ }
406
+
407
+ .generated-image-secondary-button {
408
+ background: transparent;
409
+ color: var(--text-secondary);
410
+ }
411
+
412
+ .generated-image-primary-button {
413
+ border-color: var(--accent);
414
+ background: var(--accent);
415
+ color: #fff;
416
+ }
417
+
418
+ .generated-image-primary-button--replace {
419
+ border-color: var(--warning);
420
+ background: var(--warning);
421
+ color: var(--bg);
422
+ }
423
+
424
+ .generated-image-primary-button:disabled {
425
+ cursor: wait;
426
+ opacity: 0.6;
427
+ }
428
+
429
+ .generated-image-primary-button .lucide {
430
+ width: 14px;
431
+ height: 14px;
432
+ }
433
+
212
434
  @media (max-width: 560px) {
213
435
  .generated-image-row { padding: 0 12px; }
214
436
  .generated-image-card { border-radius: 12px; }
@@ -222,4 +444,24 @@
222
444
  justify-content: center;
223
445
  padding: 0;
224
446
  }
447
+
448
+ .generated-image-details-backdrop { padding: 10px; }
449
+
450
+ .generated-image-details {
451
+ max-height: calc(100dvh - 20px);
452
+ border-radius: 13px;
453
+ }
454
+
455
+ .generated-image-details-body {
456
+ grid-template-columns: 1fr;
457
+ gap: 16px;
458
+ padding: 16px;
459
+ }
460
+
461
+ .generated-image-details-preview {
462
+ grid-row: auto;
463
+ width: min(220px, 100%);
464
+ }
465
+
466
+ .generated-image-details-preview img { max-height: 220px; }
225
467
  }
@@ -2282,7 +2282,7 @@
2282
2282
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0/lib/addon-fit.min.js"></script>
2283
2283
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0/lib/addon-web-links.min.js"></script>
2284
2284
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0/lib/addon-webgl.min.js"></script>
2285
- <script type="module" src="app.js?v=20260829-terminal-dark1"></script>
2285
+ <script type="module" src="app.js?v=20260829-image-save-details1"></script>
2286
2286
  <div id="pwa-install-modal" class="pwa-modal hidden">
2287
2287
  <div class="pwa-modal-backdrop"></div>
2288
2288
  <div class="pwa-modal-card">
@@ -3,6 +3,11 @@
3
3
  import { refreshIcons, iconHtml } from './icons.js';
4
4
  import { showImageModal } from './app-misc.js';
5
5
  import { addToMessages, scrollToBottom } from './app-rendering.js';
6
+ import { store } from './store.js';
7
+ import { copyToClipboard, showToast } from './utils.js';
8
+
9
+ var detailsModal = null;
10
+ var detailsEscapeHandler = null;
6
11
 
7
12
  function fileNameFromImage(image) {
8
13
  if (image.fileName) return image.fileName;
@@ -24,6 +29,202 @@ function actionButton(icon, label) {
24
29
  return button;
25
30
  }
26
31
 
32
+ function closeImageDetails() {
33
+ if (detailsModal) detailsModal.remove();
34
+ if (detailsEscapeHandler) document.removeEventListener('keydown', detailsEscapeHandler);
35
+ detailsModal = null;
36
+ detailsEscapeHandler = null;
37
+ }
38
+
39
+ function setActionState(button, icon, label) {
40
+ if (!button) return;
41
+ button.innerHTML = iconHtml(icon) + '<span>' + label + '</span>';
42
+ button.title = label;
43
+ button.setAttribute('aria-label', label);
44
+ refreshIcons(button);
45
+ }
46
+
47
+ function saveGeneratedImage(image, targetPath, overwrite) {
48
+ var basePath = store.get('basePath') || '';
49
+ return fetch(basePath + 'api/generated-image/save', {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ credentials: 'same-origin',
53
+ body: JSON.stringify({
54
+ fileName: fileNameFromImage(image),
55
+ path: targetPath,
56
+ overwrite: !!overwrite,
57
+ }),
58
+ }).then(function (response) {
59
+ return response.text().then(function (text) {
60
+ var data = {};
61
+ try { data = JSON.parse(text); } catch (e) { data.error = text || 'Could not save image'; }
62
+ data.status = response.status;
63
+ data.ok = response.ok && data.ok !== false;
64
+ return data;
65
+ });
66
+ });
67
+ }
68
+
69
+ function showGeneratedImageDetails(image, promptText, sourceSaveButton, focusPath) {
70
+ closeImageDetails();
71
+ var prompt = promptText || '';
72
+ var fileName = fileNameFromImage(image);
73
+ var overwrite = false;
74
+
75
+ var backdrop = document.createElement('div');
76
+ backdrop.className = 'generated-image-details-backdrop';
77
+ var modal = document.createElement('section');
78
+ modal.className = 'generated-image-details';
79
+ modal.setAttribute('role', 'dialog');
80
+ modal.setAttribute('aria-modal', 'true');
81
+ modal.setAttribute('aria-labelledby', 'generated-image-details-title');
82
+
83
+ var header = document.createElement('header');
84
+ header.className = 'generated-image-details-header';
85
+ var heading = document.createElement('div');
86
+ var eyebrow = document.createElement('span');
87
+ eyebrow.className = 'generated-image-details-eyebrow';
88
+ eyebrow.textContent = 'IMAGE GENERATION';
89
+ var title = document.createElement('h2');
90
+ title.id = 'generated-image-details-title';
91
+ title.textContent = 'Image details';
92
+ heading.appendChild(eyebrow);
93
+ heading.appendChild(title);
94
+ var closeButton = actionButton('x', 'Close');
95
+ closeButton.classList.add('generated-image-details-close');
96
+ closeButton.addEventListener('click', closeImageDetails);
97
+ header.appendChild(heading);
98
+ header.appendChild(closeButton);
99
+ modal.appendChild(header);
100
+
101
+ var body = document.createElement('div');
102
+ body.className = 'generated-image-details-body';
103
+ var preview = document.createElement('div');
104
+ preview.className = 'generated-image-details-preview';
105
+ var previewImage = document.createElement('img');
106
+ previewImage.src = image.url;
107
+ previewImage.alt = prompt ? 'Generated image: ' + prompt : 'Generated image';
108
+ previewImage.addEventListener('click', function () { showImageModal(image.url); });
109
+ preview.appendChild(previewImage);
110
+ body.appendChild(preview);
111
+
112
+ var promptSection = document.createElement('section');
113
+ promptSection.className = 'generated-image-details-section';
114
+ var promptHeader = document.createElement('div');
115
+ promptHeader.className = 'generated-image-details-section-header';
116
+ var promptLabel = document.createElement('h3');
117
+ promptLabel.textContent = 'Prompt';
118
+ promptHeader.appendChild(promptLabel);
119
+ if (prompt) {
120
+ var copyPrompt = actionButton('copy', 'Copy prompt');
121
+ copyPrompt.addEventListener('click', function () { copyToClipboard(prompt); });
122
+ promptHeader.appendChild(copyPrompt);
123
+ }
124
+ var promptBody = document.createElement('div');
125
+ promptBody.className = 'generated-image-details-prompt';
126
+ promptBody.textContent = prompt || 'Prompt details are not available for this image.';
127
+ promptSection.appendChild(promptHeader);
128
+ promptSection.appendChild(promptBody);
129
+ body.appendChild(promptSection);
130
+
131
+ var saveSection = document.createElement('section');
132
+ saveSection.className = 'generated-image-details-section generated-image-save-section';
133
+ var saveLabel = document.createElement('label');
134
+ saveLabel.htmlFor = 'generated-image-project-path';
135
+ saveLabel.textContent = 'Project path';
136
+ var pathInput = document.createElement('input');
137
+ pathInput.id = 'generated-image-project-path';
138
+ pathInput.type = 'text';
139
+ pathInput.value = 'assets/generated/' + fileName;
140
+ pathInput.spellcheck = false;
141
+ pathInput.autocomplete = 'off';
142
+ var saveHint = document.createElement('p');
143
+ saveHint.className = 'generated-image-save-hint';
144
+ saveHint.textContent = 'The folder will be created inside this project if needed.';
145
+ var saveError = document.createElement('p');
146
+ saveError.className = 'generated-image-save-error hidden';
147
+ saveSection.appendChild(saveLabel);
148
+ saveSection.appendChild(pathInput);
149
+ saveSection.appendChild(saveHint);
150
+ saveSection.appendChild(saveError);
151
+ body.appendChild(saveSection);
152
+ modal.appendChild(body);
153
+
154
+ var footer = document.createElement('footer');
155
+ footer.className = 'generated-image-details-footer';
156
+ var cancelButton = document.createElement('button');
157
+ cancelButton.type = 'button';
158
+ cancelButton.className = 'generated-image-secondary-button';
159
+ cancelButton.textContent = 'Cancel';
160
+ cancelButton.addEventListener('click', closeImageDetails);
161
+ var saveButton = document.createElement('button');
162
+ saveButton.type = 'button';
163
+ saveButton.className = 'generated-image-primary-button';
164
+ saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
165
+ footer.appendChild(cancelButton);
166
+ footer.appendChild(saveButton);
167
+ modal.appendChild(footer);
168
+ backdrop.appendChild(modal);
169
+ document.body.appendChild(backdrop);
170
+ detailsModal = backdrop;
171
+
172
+ function resetOverwrite() {
173
+ overwrite = false;
174
+ saveError.classList.add('hidden');
175
+ saveError.textContent = '';
176
+ saveButton.classList.remove('generated-image-primary-button--replace');
177
+ saveButton.innerHTML = iconHtml('save') + '<span>Save to project</span>';
178
+ refreshIcons(saveButton);
179
+ }
180
+
181
+ pathInput.addEventListener('input', resetOverwrite);
182
+ saveButton.addEventListener('click', function () {
183
+ var targetPath = pathInput.value.trim();
184
+ if (!targetPath) {
185
+ saveError.textContent = 'Enter a project path.';
186
+ saveError.classList.remove('hidden');
187
+ pathInput.focus();
188
+ return;
189
+ }
190
+ saveButton.disabled = true;
191
+ saveError.classList.add('hidden');
192
+ saveGeneratedImage(image, targetPath, overwrite).then(function (result) {
193
+ saveButton.disabled = false;
194
+ if (result.ok) {
195
+ setActionState(sourceSaveButton, 'check', 'Saved');
196
+ if (sourceSaveButton) sourceSaveButton.disabled = true;
197
+ showToast('Saved to ' + result.path);
198
+ closeImageDetails();
199
+ return;
200
+ }
201
+ saveError.textContent = result.error || 'Could not save image.';
202
+ saveError.classList.remove('hidden');
203
+ if (result.status === 409) {
204
+ overwrite = true;
205
+ saveButton.classList.add('generated-image-primary-button--replace');
206
+ saveButton.innerHTML = iconHtml('replace') + '<span>Replace file</span>';
207
+ refreshIcons(saveButton);
208
+ }
209
+ }).catch(function () {
210
+ saveButton.disabled = false;
211
+ saveError.textContent = 'Could not save image.';
212
+ saveError.classList.remove('hidden');
213
+ });
214
+ });
215
+
216
+ backdrop.addEventListener('click', function (event) {
217
+ if (event.target === backdrop) closeImageDetails();
218
+ });
219
+ detailsEscapeHandler = function (event) {
220
+ if (event.key === 'Escape') closeImageDetails();
221
+ };
222
+ document.addEventListener('keydown', detailsEscapeHandler);
223
+ refreshIcons(backdrop);
224
+ if (focusPath) pathInput.focus();
225
+ else closeButton.focus();
226
+ }
227
+
27
228
  function findProgressRow(toolId) {
28
229
  var rows = document.querySelectorAll('.generated-image-row[data-image-tool-id]');
29
230
  for (var i = 0; i < rows.length; i++) {
@@ -99,10 +300,12 @@ export function renderGeneratedImage(msg) {
99
300
  label.innerHTML = iconHtml('sparkles') + '<span>Generated image</span>';
100
301
  meta.appendChild(label);
101
302
  if (msg.prompt) {
102
- var prompt = document.createElement('span');
303
+ var prompt = document.createElement('button');
304
+ prompt.type = 'button';
103
305
  prompt.className = 'generated-image-prompt';
104
306
  prompt.textContent = msg.prompt;
105
- prompt.title = msg.prompt;
307
+ prompt.title = 'View full prompt';
308
+ prompt.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, null, false); });
106
309
  meta.appendChild(prompt);
107
310
  }
108
311
  footer.appendChild(meta);
@@ -112,6 +315,12 @@ export function renderGeneratedImage(msg) {
112
315
  var openButton = actionButton('maximize-2', 'Open');
113
316
  openButton.addEventListener('click', function () { showImageModal(image.url); });
114
317
  actions.appendChild(openButton);
318
+ var saveButton = actionButton('save', 'Save');
319
+ saveButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, true); });
320
+ var detailsButton = actionButton('info', 'Details');
321
+ detailsButton.addEventListener('click', function () { showGeneratedImageDetails(image, msg.prompt, saveButton, false); });
322
+ actions.appendChild(detailsButton);
323
+ actions.appendChild(saveButton);
115
324
  var downloadLink = document.createElement('a');
116
325
  downloadLink.className = 'generated-image-action';
117
326
  downloadLink.href = image.url;
@@ -8,7 +8,7 @@
8
8
  @import url("css/overlays.css?v=20260827-brand3");
9
9
  @import url("css/menus.css");
10
10
  @import url("css/messages.css?v=20260828-delegated-muted1");
11
- @import url("css/generated-images.css?v=20260829c");
11
+ @import url("css/generated-images.css?v=20260829-save-details1");
12
12
  @import url("css/rewind.css");
13
13
  @import url("css/input.css?v=20260828-composer-depth1");
14
14
  @import url("css/filebrowser.css?v=20260829-terminal-canvas1");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.7.0-beta.2",
3
+ "version": "3.8.0-beta.1",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",