shadok-ai 0.15.49 → 0.15.51

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/README.md CHANGED
@@ -204,8 +204,8 @@ Enter to skip; you can add it later from the web UI).
204
204
  browser, since shell mode sits outside the agent's guardrails. The command and
205
205
  its output stay in the chat, reload included — as does one you typed yourself
206
206
  in the terminal view.
207
- - **Files you hand an agent** — paste or drop a file into the composer (an
208
- image, a PDF, a CSV…) and it becomes a **chip** above the text box: a
207
+ - **Files you hand an agent** — paste or drop a file into the composer, or pick
208
+ one with the 📎 button beside it (an image, a PDF, a CSV…), and it becomes a **chip** above the text box: a
209
209
  thumbnail for an image, a named tag for anything else, each with a ✕ to
210
210
  remove it. On send, an image shows **inline in your own message bubble**;
211
211
  under the hood the agent still receives it as an `[Attached …: path]` line,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.49",
3
+ "version": "0.15.51",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
@@ -24,3 +24,27 @@ export function echoAuthor(msg) {
24
24
  // from another of your tabs; a single cockpit has one human.
25
25
  return place || "you";
26
26
  }
27
+
28
+ /**
29
+ * Is this prompt the viewer's OWN? Decides the side of the bubble: yours on the
30
+ * right, anyone else's — a teammate on the web, a Telegram sender, a cron, the
31
+ * auto-retry — on the left, still tinted as a human message (only the agent is
32
+ * white).
33
+ *
34
+ * Only a web prompt can be yours: another platform is another person, or nobody.
35
+ * Without a name it is a prompt from this browser or a cockpit with no accounts
36
+ * — yours. While `me` is not known yet (`/me` still in flight), a named web
37
+ * prompt is taken as yours too: guessing "someone else" would flash your own
38
+ * history to the left on every reload.
39
+ *
40
+ * @param {{from?: string, origin?: string, auto?: boolean}} msg
41
+ * @param {string | null | undefined} me the signed-in account's name, if known
42
+ */
43
+ export function isOwnPrompt(msg, me) {
44
+ if (msg?.auto) return false;
45
+ const origin = (msg?.origin ?? "").trim();
46
+ if (origin && origin !== "web") return false;
47
+ const from = (msg?.from ?? "").trim();
48
+ if (!from || !me) return true;
49
+ return from === me;
50
+ }
package/public/index.html CHANGED
@@ -1128,6 +1128,11 @@
1128
1128
  between this bubble and the one before, so it keeps its own label — a
1129
1129
  wordless boundary the reader would otherwise never see. */
1130
1130
  .turn.user { align-self: flex-end; align-items: flex-end; }
1131
+ /* Someone else's prompt (a teammate, a Telegram sender, a cron): on the
1132
+ agent's side, still a human bubble — same tint as yours, only the agent is
1133
+ white. The side and the name say who; a colour per person read as noise. */
1134
+ .turn.user.other { align-self: flex-start; align-items: flex-start; }
1135
+ .turn.user + .turn.user.new-speaker > .label { display: block; }
1131
1136
  .turn.claude { align-self: flex-start; }
1132
1137
  .bubble {
1133
1138
  padding: 9px 14px;
@@ -1535,6 +1540,25 @@
1535
1540
  line-height: 1.45;
1536
1541
  }
1537
1542
  #composer textarea:focus-visible { outline: none; border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-soft); }
1543
+ /* The paperclip: a square as tall as the one-line textarea, aligned to its
1544
+ bottom so it stays beside the last line while the field grows. */
1545
+ .cmp-attach {
1546
+ flex: none;
1547
+ align-self: flex-end;
1548
+ width: 42px;
1549
+ height: 42px;
1550
+ padding: 0;
1551
+ display: inline-flex;
1552
+ align-items: center;
1553
+ justify-content: center;
1554
+ background: transparent;
1555
+ color: var(--text-dim);
1556
+ border: 1px solid var(--line);
1557
+ border-radius: var(--radius);
1558
+ cursor: pointer;
1559
+ }
1560
+ .cmp-attach:hover:not(:disabled) { color: var(--amber); border-color: var(--amber); }
1561
+ .cmp-attach:disabled { opacity: 0.4; cursor: default; }
1538
1562
 
1539
1563
  /* ── Engine room (live terminal) ─────────────────────── */
1540
1564
  #machine {
@@ -2946,6 +2970,11 @@
2946
2970
  <form id="composer" hidden>
2947
2971
  <!-- Thumbnails of the files dropped / pasted into this agent's composer. -->
2948
2972
  <div id="composerAtts" class="cmp-atts" hidden></div>
2973
+ <!-- Pick a file to send: the same path as a paste or a drop (attachFile). -->
2974
+ <button type="button" id="attachBtn" class="cmp-attach" title="Attach a file" aria-label="Attach a file">
2975
+ <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M21 11.5l-8.6 8.6a5.5 5.5 0 0 1-7.8-7.8l8.6-8.6a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
2976
+ </button>
2977
+ <input type="file" id="attachInput" multiple hidden>
2949
2978
  <textarea id="promptInput" rows="1"
2950
2979
  placeholder="Write your prompt — Enter to send, Shift+Enter for a new line"></textarea>
2951
2980
  <button type="submit" class="primary" id="sendBtn">Send</button>
@@ -3023,7 +3052,7 @@
3023
3052
  hasReadonlyPreset,
3024
3053
  applyReadonlyPreset, isManagedProfile, profileSaveBody, secretUsers } from "/profile-card.js?v=__ASSET_V__";
3025
3054
  import { notifyState, BLINK_MS } from "/notify.js?v=__ASSET_V__";
3026
- import { echoAuthor } from "/echo-author.js?v=__ASSET_V__";
3055
+ import { echoAuthor, isOwnPrompt } from "/echo-author.js?v=__ASSET_V__";
3027
3056
  import { dialPos, dialColor, arcSegments, dialTitle, SWEEP_DEG } from "/gauge-dial.js?v=__ASSET_V__";
3028
3057
  import { TOUR_STEPS, bubblePlacement, unionRect, visibleSteps } from "/tour-steps.js?v=__ASSET_V__";
3029
3058
  import { pickChannelSource, dirKey, serverSwapped } from "/channel-store.js?v=__ASSET_V__";
@@ -3081,6 +3110,7 @@
3081
3110
  window.hasReadonlyPreset = hasReadonlyPreset;
3082
3111
  window.applyReadonlyPreset = applyReadonlyPreset;
3083
3112
  window.echoAuthor = echoAuthor;
3113
+ window.isOwnPrompt = isOwnPrompt;
3084
3114
  window.notifyState = notifyState;
3085
3115
  window.BLINK_MS = BLINK_MS;
3086
3116
  window.dialPos = dialPos;
@@ -3130,6 +3160,7 @@
3130
3160
  extractLiveText: () => "",
3131
3161
  livePreviewDecision: () => null,
3132
3162
  echoAuthor: () => "",
3163
+ isOwnPrompt: () => true,
3133
3164
  notifyState: () => ({ color: null, badge: "", blink: false }),
3134
3165
  dialPos: () => 0,
3135
3166
  dialColor: () => "",
@@ -5896,6 +5927,32 @@
5896
5927
  return window.echoAuthor ? window.echoAuthor(msg) : "you";
5897
5928
  }
5898
5929
 
5930
+ /** Which side a human prompt sits on: yours on the right, anyone else's on
5931
+ * the left — still a tinted human bubble, only the agent is white. The author
5932
+ * is kept on the turn so the side can be re-decided once `/me` answers
5933
+ * (`resideAllPrompts`). */
5934
+ function markPromptSide(bubble, msg) {
5935
+ const turn = bubble.parentElement;
5936
+ turn.dataset.from = msg?.from ?? "";
5937
+ turn.dataset.origin = msg?.origin ?? "";
5938
+ if (msg?.auto) turn.dataset.auto = "1";
5939
+ // A run of prompts shows one label — but only while it is ONE speaker.
5940
+ const prev = turn.previousElementSibling;
5941
+ const who = turn.querySelector(":scope > .label")?.firstChild?.textContent ?? "";
5942
+ const prevWho = prev?.classList.contains("user") ? prev.querySelector(":scope > .label")?.firstChild?.textContent ?? "" : who;
5943
+ turn.classList.toggle("new-speaker", who !== prevWho);
5944
+ applyPromptSide(turn);
5945
+ }
5946
+ function applyPromptSide(turn) {
5947
+ const msg = { from: turn.dataset.from, origin: turn.dataset.origin, auto: turn.dataset.auto === "1" };
5948
+ const own = window.isOwnPrompt ? window.isOwnPrompt(msg, me?.name) : true;
5949
+ turn.classList.toggle("other", !own);
5950
+ }
5951
+ function resideAllPrompts() {
5952
+ for (const turn of document.querySelectorAll(".turn.user[data-origin]")) applyPromptSide(turn);
5953
+ }
5954
+ window.addEventListener("shadok-bridge-ready", resideAllPrompts);
5955
+
5899
5956
  function addTurn(tab, role, who, text, extraClass, at) {
5900
5957
  // Time of the last message in the channel list, relative ("3m ago"). Every
5901
5958
  // message (replayed history or live) carries `at`; the last one wins.
@@ -7399,6 +7456,7 @@
7399
7456
  // SAME echoAuthor the live echo uses: "Ada · telegram"
7400
7457
  // for a Telegram message, "you" for your own web prompt.
7401
7458
  const b = addTurn(t, "user", echoAuthorLabel({ from: h.from, origin: h.origin }), h.text, "hist", h.at);
7459
+ markPromptSide(b, { from: h.from, origin: h.origin });
7402
7460
  stampMsgIds(t, b.parentElement, h.ids);
7403
7461
  } else {
7404
7462
  const b = addTurn(t, "claude", "claude", h.text, "hist", h.at);
@@ -7513,6 +7571,7 @@
7513
7571
  closeActivity(t);
7514
7572
  {
7515
7573
  const b = addTurn(t, "user", echoAuthorLabel(msg), msg.text, null, Date.now());
7574
+ markPromptSide(b, msg);
7516
7575
  awaitPromptId(b, msg.text);
7517
7576
  markUnreadBoundary(t, b.parentElement);
7518
7577
  }
@@ -7969,7 +8028,11 @@
7969
8028
  // Sending = going back to the bottom, whatever happens (even after scrolling
7970
8029
  // up): you want to see your message and the answer that follows.
7971
8030
  t.transcriptEl._stickBottom = true;
7972
- awaitPromptId(addTurn(t, "user", "you", text, null, Date.now()), text);
8031
+ {
8032
+ const b = addTurn(t, "user", "you", text, null, Date.now());
8033
+ markPromptSide(b, {});
8034
+ awaitPromptId(b, text);
8035
+ }
7973
8036
  startTurnTimer(t);
7974
8037
  setTabMood(t, "working");
7975
8038
  // Remember this channel's last sent prompt, so Escape (an interruption) can
@@ -8216,6 +8279,17 @@
8216
8279
  for (const item of items) await attachFile(item.getAsFile());
8217
8280
  });
8218
8281
 
8282
+ // The paperclip opens the OS file picker; what is picked goes through
8283
+ // attachFile exactly like a paste or a drop.
8284
+ const attachInput = $("attachInput");
8285
+ $("attachBtn").addEventListener("click", () => { if (active) attachInput.click(); });
8286
+ attachInput.addEventListener("change", async () => {
8287
+ const files = [...(attachInput.files || [])];
8288
+ attachInput.value = ""; // picking the same file again must fire `change` again
8289
+ for (const f of files) await attachFile(f);
8290
+ promptInput.focus();
8291
+ });
8292
+
8219
8293
  // Drag-and-drop files anywhere on the chat pane → attach them, same path as
8220
8294
  // paste. Only reacts to an OS file drag (`types` includes "Files"), so the
8221
8295
  // internal tab/group reordering DnD is untouched. A depth counter keeps the
@@ -9497,6 +9571,8 @@
9497
9571
  $("signOut").hidden = !named;
9498
9572
  $("whoSep").hidden = !named;
9499
9573
  if (named) $("whoAmI").textContent = "Signed in as " + me.name + " · " + me.role;
9574
+ // Prompts drawn before we knew who you are get their side now.
9575
+ resideAllPrompts();
9500
9576
  // Security: for a signed-in person (or the lone admin of a no-password
9501
9577
  // instance, who is everybody). The server answers 403 otherwise.
9502
9578
  $("securityBtn").hidden = !(me?.name || me?.role === "admin");