shadok-ai 0.2.67 → 0.2.68

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +85 -37
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts .claude/skills/shadok-ai-agents/test/*.test.mjs",
package/public/index.html CHANGED
@@ -594,7 +594,18 @@
594
594
  /* min-height:0 pour que le transcript (flex:1) borne la hauteur au lieu de
595
595
  pousser le châssis ; overflow-y:auto garde l'écran de setup atteignable s'il
596
596
  dépasse (quand une session tourne, le transcript remplit exactement → inerte). */
597
- .chat-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow-y: auto; }
597
+ .chat-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow-y: auto; position: relative; }
598
+ /* File drag-and-drop target: a dashed accent overlay while a file hovers.
599
+ pointer-events:none so the drop still lands on the pane underneath. */
600
+ .chat-pane.dropping::after {
601
+ content: "+ Drop files to attach";
602
+ position: absolute; inset: 6px; z-index: 40;
603
+ display: flex; align-items: center; justify-content: center;
604
+ background: color-mix(in srgb, var(--amber) 12%, rgba(8, 9, 13, 0.6));
605
+ border: 2px dashed var(--amber); border-radius: 10px;
606
+ color: var(--amber); font-family: var(--mono); font-size: 15px; letter-spacing: 0.04em;
607
+ pointer-events: none;
608
+ }
598
609
 
599
610
  .field { display: flex; flex-direction: column; gap: 6px; }
600
611
  input[type="text"] {
@@ -727,12 +738,12 @@
727
738
  /* Copier : toujours visible, jamais au survol — sur un téléphone il n'y a pas
728
739
  de survol, et un bouton qu'on ne peut pas révéler n'existe pas. */
729
740
  .turn .copy {
730
- align-self: flex-end; margin-top: 2px; padding: 2px 6px;
731
- font-size: 10px; line-height: 1.4; cursor: pointer;
732
- background: none; border: 1px solid var(--line); border-radius: 6px;
733
- color: var(--text-dim); opacity: 0.5;
741
+ align-self: flex-end; margin-top: 2px; padding: 3px 8px;
742
+ font-size: 11px; line-height: 1.4; cursor: pointer;
743
+ background: var(--bg-inset); border: 1px solid var(--line); border-radius: 6px;
744
+ color: var(--text-dim); opacity: 0.9; transition: color 0.12s, border-color 0.12s;
734
745
  }
735
- .turn .copy:hover, .turn .copy:focus-visible { opacity: 1; }
746
+ .turn .copy:hover, .turn .copy:focus-visible { opacity: 1; color: var(--amber); border-color: var(--amber); }
736
747
  .turn .copy.done { opacity: 1; color: var(--ok, #7ac77a); border-color: currentColor; }
737
748
  /* An activity block sits closer to the text it belongs to than to the next turn. */
738
749
  .turn.activity { margin-top: 5px; }
@@ -4648,45 +4659,82 @@
4648
4659
  * surface. On l'INSÈRE dans le composeur au lieu d'envoyer tout de suite —
4649
4660
  * l'utilisateur voit ce qui part et peut encore écrire sa consigne.
4650
4661
  */
4651
- let pasteSeq = 0; // makes each placeholder unique so N pastes don't collide
4662
+ let pasteSeq = 0; // makes each placeholder unique so N attachments don't collide
4663
+ // Upload ONE file to /paste and swap its placeholder for the ready-made line.
4664
+ // Shared by paste and drag-and-drop so both behave identically.
4665
+ async function attachFile(file) {
4666
+ if (!file) return;
4667
+ const isImg = (file.type || "").startsWith("image/");
4668
+ const name = file.name || "";
4669
+ const label = name || (isImg ? "image" : "fichier");
4670
+ // Zero-width spaces keep the visible label clean while making the marker
4671
+ // unique — replace() only hits the first match, so identical labels
4672
+ // (two screenshots are both "image.png") must not share a placeholder.
4673
+ const mark = "⏳ " + label + "…" + "​".repeat(++pasteSeq);
4674
+ insertAtCursor(promptInput, mark + "\n");
4675
+ try {
4676
+ const r = await fetch("/paste", {
4677
+ method: "POST",
4678
+ headers: {
4679
+ "content-type": file.type || "application/octet-stream",
4680
+ "x-filename": encodeURIComponent(name),
4681
+ },
4682
+ body: file,
4683
+ });
4684
+ const j = await r.json();
4685
+ if (!r.ok || !j.line) throw new Error(j.error || `HTTP ${r.status}`);
4686
+ promptInput.value = promptInput.value.replace(mark, j.line);
4687
+ } catch (err) {
4688
+ // Le dire dans le composeur : un marqueur qui reste sans explication
4689
+ // laisserait croire à un envoi en cours qui n'arrivera jamais.
4690
+ promptInput.value = promptInput.value.replace(mark, `⚠️ ${label} non envoyé (${err.message})`);
4691
+ }
4692
+ promptInput.dispatchEvent(new Event("input")); // hauteur + brouillon
4693
+ }
4694
+
4652
4695
  promptInput.addEventListener("paste", async (e) => {
4653
4696
  // ANY file, not just images — a PDF, a CSV, whatever. `kind === "file"`
4654
4697
  // captures real files while leaving an ordinary text paste untouched.
4655
4698
  const items = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === "file");
4656
4699
  if (!items.length) return;
4657
4700
  e.preventDefault();
4658
- for (const item of items) {
4659
- const blob = item.getAsFile();
4660
- if (!blob) continue;
4661
- const isImg = (blob.type || "").startsWith("image/");
4662
- const name = blob.name || "";
4663
- const label = name || (isImg ? "image" : "fichier");
4664
- // Zero-width spaces keep the visible label clean while making the marker
4665
- // unique — replace() only hits the first match, so identical labels
4666
- // (two screenshots are both "image.png") must not share a placeholder.
4667
- const mark = "⏳ " + label + "…" + "​".repeat(++pasteSeq);
4668
- insertAtCursor(promptInput, mark + "\n");
4669
- try {
4670
- const r = await fetch("/paste", {
4671
- method: "POST",
4672
- headers: {
4673
- "content-type": blob.type || "application/octet-stream",
4674
- "x-filename": encodeURIComponent(name),
4675
- },
4676
- body: blob,
4677
- });
4678
- const j = await r.json();
4679
- if (!r.ok || !j.line) throw new Error(j.error || `HTTP ${r.status}`);
4680
- promptInput.value = promptInput.value.replace(mark, j.line);
4681
- } catch (err) {
4682
- // Le dire dans le composeur : un marqueur qui reste sans explication
4683
- // laisserait croire à un envoi en cours qui n'arrivera jamais.
4684
- promptInput.value = promptInput.value.replace(mark, `⚠️ ${label} non envoyé (${err.message})`);
4685
- }
4686
- promptInput.dispatchEvent(new Event("input")); // hauteur + brouillon
4687
- }
4701
+ for (const item of items) await attachFile(item.getAsFile());
4688
4702
  });
4689
4703
 
4704
+ // Drag-and-drop files anywhere on the chat pane → attach them, same path as
4705
+ // paste. Only reacts to an OS file drag (`types` includes "Files"), so the
4706
+ // internal tab/group reordering DnD is untouched. A depth counter keeps the
4707
+ // highlight steady while the cursor crosses child elements.
4708
+ const dropZone = document.querySelector(".chat-pane");
4709
+ if (dropZone) {
4710
+ const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
4711
+ let dragDepth = 0;
4712
+ const clearDrop = () => { dragDepth = 0; dropZone.classList.remove("dropping"); };
4713
+ dropZone.addEventListener("dragenter", (e) => {
4714
+ if (!hasFiles(e) || !active) return;
4715
+ e.preventDefault();
4716
+ dragDepth++;
4717
+ dropZone.classList.add("dropping");
4718
+ });
4719
+ dropZone.addEventListener("dragover", (e) => {
4720
+ if (!hasFiles(e) || !active) return;
4721
+ e.preventDefault();
4722
+ e.dataTransfer.dropEffect = "copy";
4723
+ });
4724
+ dropZone.addEventListener("dragleave", (e) => {
4725
+ if (!hasFiles(e)) return;
4726
+ if (--dragDepth <= 0) clearDrop();
4727
+ });
4728
+ dropZone.addEventListener("drop", async (e) => {
4729
+ if (!hasFiles(e)) return;
4730
+ e.preventDefault();
4731
+ clearDrop();
4732
+ if (!active) return; // no composer to attach to
4733
+ for (const f of [...(e.dataTransfer.files || [])]) await attachFile(f);
4734
+ promptInput.focus();
4735
+ });
4736
+ }
4737
+
4690
4738
  /** Insère du texte à la position du curseur, sans écraser la saisie en cours. */
4691
4739
  function insertAtCursor(el, text) {
4692
4740
  const start = el.selectionStart ?? el.value.length;