@oddessentials/agent-guild 0.17.0 → 0.19.0

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
@@ -56,7 +56,9 @@ own. The cloud icon on each card opens the vendor's web app.
56
56
  **Real terminals that outlive the page.** Every session is a full interactive
57
57
  terminal: type instructions, answer prompts, watch output. Run as many as you
58
58
  like. A local session manager owns them, so you can close or reload the page
59
- and come back to the same screens.
59
+ and come back to the same screens. Drag a session card by its grip, with a
60
+ mouse or a finger, to put the cards in the order you want; this browser
61
+ remembers it, and new sessions join at the end.
60
62
 
61
63
  **Pick your shell.** The Shell card offers the shells installed on your
62
64
  computer and remembers your choice in this browser. On Windows it defaults
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -12,6 +12,7 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
12
12
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
13
13
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
14
14
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
15
+ const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
15
16
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
16
17
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
17
18
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -2585,6 +2586,8 @@ function useFolder(dir) {
2585
2586
 
2586
2587
  const cards = new Map();
2587
2588
  let sessionsShown = false;
2589
+ /** Session ids in the order the user arranged the cards, kept in this browser. */
2590
+ let sessionOrder = parseOrder(load(SESSION_ORDER_KEY));
2588
2591
 
2589
2592
  const MODEL_SOURCES = { report: 'reported by the tool', screen: 'seen on the tool\'s screen', args: 'from the --model argument' };
2590
2593
 
@@ -2632,6 +2635,7 @@ function buildCard(session) {
2632
2635
  if (id) copyId(id);
2633
2636
  });
2634
2637
  node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
2638
+ node.querySelector('.drag-handle').addEventListener('keydown', (e) => moveByKey(e, session.id));
2635
2639
  // Skins name their own keyframes, so the one-shot classes clear on any animation they run.
2636
2640
  node.addEventListener('animationend', (e) => {
2637
2641
  if (e.target.classList.contains('level-up')) e.target.classList.remove('level-up');
@@ -2673,6 +2677,7 @@ function updateCard(node, s) {
2673
2677
  badge.textContent = level;
2674
2678
  badge.title = `Level ${level}`;
2675
2679
  node.querySelector('.name').textContent = s.name;
2680
+ node.querySelector('.drag-handle').setAttribute('aria-label', `Move ${s.name}`);
2676
2681
  const id = toolSessionId(s);
2677
2682
  const resumed = s.resume && s.resume !== id ? ` · resumed ${s.resume}` : s.resume ? ' · resumed' : '';
2678
2683
  node.querySelector('.meta-text').textContent = [s.provider.vendor, s.provider.tool, accountLabel(s), `started ${relativeTime(s.createdAt)}${resumed}`].filter(Boolean).join(' · ');
@@ -2710,9 +2715,12 @@ function updateCard(node, s) {
2710
2715
 
2711
2716
  function renderSessions() {
2712
2717
  const grid = $('sessions');
2713
- const sessions = [...state.sessions.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
2718
+ const sessions = orderSessions(state.sessions.values(), sessionOrder);
2714
2719
  for (const [id, node] of cards) {
2715
- if (!state.sessions.has(id)) { node.remove(); cards.delete(id); }
2720
+ if (state.sessions.has(id)) continue;
2721
+ if (drag?.id === id) releaseDrag();
2722
+ node.remove();
2723
+ cards.delete(id);
2716
2724
  }
2717
2725
  sessions.forEach((s, index) => {
2718
2726
  let node = cards.get(s.id);
@@ -2722,6 +2730,8 @@ function renderSessions() {
2722
2730
  if (sessionsShown) node.classList.add('enter');
2723
2731
  }
2724
2732
  updateCard(node, s);
2733
+ // With one card there is nothing to reorder.
2734
+ node.querySelector('.drag-handle').hidden = sessions.length < 2;
2725
2735
  // Move a card only when it is out of place: re-inserting a node drops
2726
2736
  // keyboard focus and can swallow a click that is in progress.
2727
2737
  if (grid.children[index] !== node) grid.insertBefore(node, grid.children[index] || null);
@@ -2782,6 +2792,231 @@ async function renameSession(id) {
2782
2792
  try { upsertSession((await api('PATCH', `/sessions/${id}`, { name })).session); } catch (err) { toast(err.message); }
2783
2793
  }
2784
2794
 
2795
+ // ---- reordering session cards ---------------------------------------------
2796
+
2797
+ /** The saved card order: session ids. Anything unreadable counts as no order. */
2798
+ function parseOrder(raw) {
2799
+ try {
2800
+ const ids = JSON.parse(raw);
2801
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
2802
+ } catch {
2803
+ return [];
2804
+ }
2805
+ }
2806
+
2807
+ /** Sessions in the user's order. Any the order does not name, such as new ones, follow oldest first. */
2808
+ function orderSessions(sessions, order) {
2809
+ const rank = new Map(order.map((id, index) => [id, index]));
2810
+ const at = (s) => rank.get(s.id) ?? Infinity;
2811
+ return [...sessions].sort((a, b) => (at(a) - at(b)) || a.createdAt.localeCompare(b.createdAt));
2812
+ }
2813
+
2814
+ /** The ids with one of them moved to a new index. */
2815
+ function moveId(ids, id, index) {
2816
+ const rest = ids.filter((other) => other !== id);
2817
+ rest.splice(Math.max(0, Math.min(index, rest.length)), 0, id);
2818
+ return rest;
2819
+ }
2820
+
2821
+ const shownIds = () => orderSessions(state.sessions.values(), sessionOrder).map((s) => s.id);
2822
+ const sameOrder = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]);
2823
+ const REORDER_EASE = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
2824
+ /** How close to the top or bottom of the window a carried card scrolls the page. */
2825
+ const SCROLL_EDGE = 72;
2826
+
2827
+ /** The card being carried, or null. */
2828
+ let drag = null;
2829
+
2830
+ function saveOrder() {
2831
+ sessionOrder = shownIds();
2832
+ save(SESSION_ORDER_KEY, JSON.stringify(sessionOrder));
2833
+ }
2834
+
2835
+ function announceOrder(id) {
2836
+ const ids = shownIds();
2837
+ const s = state.sessions.get(id);
2838
+ if (s) $('reorder-status').textContent = `${s.name} moved to position ${ids.indexOf(id) + 1} of ${ids.length}.`;
2839
+ }
2840
+
2841
+ /** Plays a card's move from where it was drawn to where it now sits. Returns the animation, if any. */
2842
+ function slideFrom(node, was) {
2843
+ for (const animation of node.getAnimations()) if (animation.id === 'reorder') animation.cancel();
2844
+ if (reducedMotion.matches) return null;
2845
+ const now = node.getBoundingClientRect();
2846
+ const dx = was.left + was.width / 2 - (now.left + now.width / 2);
2847
+ const dy = was.top + was.height / 2 - (now.top + now.height / 2);
2848
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return null;
2849
+ const slide = node.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], { duration: 260, easing: REORDER_EASE });
2850
+ slide.id = 'reorder';
2851
+ return slide;
2852
+ }
2853
+
2854
+ /** Lays the cards out in a new order, sliding each one from its old place. */
2855
+ function arrange(order) {
2856
+ const focused = $('sessions').contains(document.activeElement) ? document.activeElement : null;
2857
+ const was = new Map([...cards.values()].map((node) => [node, node.getBoundingClientRect()]));
2858
+ sessionOrder = order;
2859
+ renderSessions();
2860
+ // Moving a card can take keyboard focus with it, as when another tab reorders the cards.
2861
+ if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
2862
+ for (const [node, rect] of was) if (node !== drag?.node) slideFrom(node, rect);
2863
+ }
2864
+
2865
+ function moveByKey(e, id) {
2866
+ if (drag || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
2867
+ const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
2868
+ if (!step && e.key !== 'Home' && e.key !== 'End') return;
2869
+ e.preventDefault();
2870
+ const ids = shownIds();
2871
+ const from = ids.indexOf(id);
2872
+ const to = e.key === 'Home' ? 0 : e.key === 'End' ? ids.length - 1 : from + step;
2873
+ if (from === -1 || to < 0 || to >= ids.length || to === from) return;
2874
+ arrange(moveId(ids, id, to));
2875
+ saveOrder();
2876
+ announceOrder(id);
2877
+ }
2878
+
2879
+ /** A card's place in the grid, ignoring the transforms that animate it. */
2880
+ function layoutBox(node) {
2881
+ return { left: node.offsetLeft, top: node.offsetTop, right: node.offsetLeft + node.offsetWidth, bottom: node.offsetTop + node.offsetHeight };
2882
+ }
2883
+
2884
+ function startDrag(e, handle) {
2885
+ const node = handle.closest('.session-card');
2886
+ const grid = $('sessions');
2887
+ if (drag || !node || !e.isPrimary || (e.pointerType === 'mouse' && e.button !== 0)) return;
2888
+ e.preventDefault();
2889
+ // Where on the card, as drawn, it was picked up: it may still be sliding into place.
2890
+ const seen = node.getBoundingClientRect();
2891
+ drag = {
2892
+ id: node.dataset.id, node, grid, pointerId: e.pointerId,
2893
+ before: sessionOrder, start: shownIds(),
2894
+ grabX: e.clientX - seen.left, grabY: e.clientY - seen.top,
2895
+ x: e.clientX, y: e.clientY,
2896
+ // The card just stepped over; it is not a target again until the pointer leaves it.
2897
+ passed: null, frame: 0,
2898
+ };
2899
+ // The grid is never moved, so it keeps the pointer while the cards inside it are.
2900
+ try { grid.setPointerCapture(e.pointerId); } catch { /* the pointer is already gone */ }
2901
+ // A card picked up again while it settles would otherwise be held in place by that animation.
2902
+ for (const animation of node.getAnimations()) if (animation.id === 'reorder') animation.cancel();
2903
+ node.classList.remove('settling');
2904
+ // The card lifts around the point it was picked up by, which stays under the pointer.
2905
+ node.style.transformOrigin = `${drag.grabX}px ${drag.grabY}px`;
2906
+ node.classList.add('dragging');
2907
+ follow();
2908
+ grid.getBoundingClientRect(); // the outline appears where the card is, rather than gliding in
2909
+ grid.classList.add('reordering');
2910
+ document.documentElement.classList.add('reordering');
2911
+ if (e.pointerType !== 'mouse' && navigator.userActivation?.hasBeenActive) navigator.vibrate?.(8);
2912
+ }
2913
+
2914
+ /** Puts the carried card under the pointer and the outline where it will land. */
2915
+ function follow() {
2916
+ const { node, grid } = drag;
2917
+ const origin = grid.getBoundingClientRect();
2918
+ const box = layoutBox(node);
2919
+ node.style.translate = `${drag.x - drag.grabX - origin.left - box.left}px ${drag.y - drag.grabY - origin.top - box.top}px`;
2920
+ grid.style.setProperty('--slot-x', `${box.left}px`);
2921
+ grid.style.setProperty('--slot-y', `${box.top}px`);
2922
+ grid.style.setProperty('--slot-w', `${box.right - box.left}px`);
2923
+ grid.style.setProperty('--slot-h', `${box.bottom - box.top}px`);
2924
+ }
2925
+
2926
+ /** Moves the carried card to the place the pointer is over, if that is another card's. */
2927
+ function retarget() {
2928
+ const { node, grid } = drag;
2929
+ const origin = grid.getBoundingClientRect();
2930
+ // The middle of the carried card, not the grip at its corner, picks the place, the same in every direction.
2931
+ const x = drag.x - drag.grabX - origin.left + node.offsetWidth / 2;
2932
+ const y = drag.y - drag.grabY - origin.top + node.offsetHeight / 2;
2933
+ const over = (n) => {
2934
+ const box = layoutBox(n);
2935
+ return x >= box.left && x < box.right && y >= box.top && y < box.bottom;
2936
+ };
2937
+ if (drag.passed && !(drag.passed.isConnected && over(drag.passed))) drag.passed = null;
2938
+ const shown = [...grid.children];
2939
+ let target = shown.find((n) => n !== node && n !== drag.passed && over(n));
2940
+ // Past the last card, beside it or below it, is the last place.
2941
+ const last = shown.at(-1);
2942
+ if (!target && last !== node && last !== drag.passed) {
2943
+ const box = layoutBox(last);
2944
+ if (y >= box.bottom || (y >= box.top && x >= box.right)) target = last;
2945
+ }
2946
+ if (!target) return;
2947
+ const next = moveId(shownIds(), drag.id, shown.indexOf(target));
2948
+ if (sameOrder(next, shownIds())) return;
2949
+ arrange(next);
2950
+ drag.passed = target;
2951
+ }
2952
+
2953
+ /** Scrolls the page while the pointer is near its top or bottom edge. Returns whether it moved. */
2954
+ function autoScroll() {
2955
+ const top = topbar.getBoundingClientRect().bottom;
2956
+ const bottom = window.innerHeight;
2957
+ let pull = 0;
2958
+ if (drag.y < top + SCROLL_EDGE) pull = -(top + SCROLL_EDGE - drag.y) / SCROLL_EDGE;
2959
+ else if (drag.y > bottom - SCROLL_EDGE) pull = (drag.y - bottom + SCROLL_EDGE) / SCROLL_EDGE;
2960
+ const step = Math.round(Math.max(-1, Math.min(1, pull)) * 18);
2961
+ if (!step) return false;
2962
+ const was = window.scrollY;
2963
+ window.scrollBy(0, step);
2964
+ return window.scrollY !== was;
2965
+ }
2966
+
2967
+ function scheduleDragFrame() {
2968
+ if (drag && !drag.frame) drag.frame = requestAnimationFrame(dragFrame);
2969
+ }
2970
+
2971
+ function dragFrame() {
2972
+ drag.frame = 0;
2973
+ const scrolled = autoScroll();
2974
+ retarget();
2975
+ if (!drag) return;
2976
+ follow();
2977
+ if (scrolled) drag.frame = requestAnimationFrame(dragFrame);
2978
+ }
2979
+
2980
+ /** Lets go of the carried card without touching the order, as when its session is removed. */
2981
+ function releaseDrag() {
2982
+ if (!drag) return null;
2983
+ const ended = drag;
2984
+ drag = null;
2985
+ cancelAnimationFrame(ended.frame);
2986
+ // The grid keeps the pointer until the button comes up, which ends the capture: a drag cancelled with
2987
+ // Escape or by leaving the window must not end in a click that opens the card under the pointer.
2988
+ ended.node.style.translate = '';
2989
+ ended.node.classList.remove('dragging');
2990
+ ended.grid.classList.remove('reordering');
2991
+ document.documentElement.classList.remove('reordering');
2992
+ return ended;
2993
+ }
2994
+
2995
+ /** Drops the carried card where it is, or with `keep` false puts every card back. */
2996
+ function endDrag(keep) {
2997
+ if (!drag) return;
2998
+ const { node } = drag;
2999
+ const was = node.getBoundingClientRect();
3000
+ const ended = releaseDrag();
3001
+ if (!keep) arrange(ended.before);
3002
+ // Compare only the sessions there before and after: one created or removed meanwhile is no move.
3003
+ const now = shownIds().filter((id) => ended.start.includes(id));
3004
+ const moved = !sameOrder(now, ended.start.filter((id) => now.includes(id)));
3005
+ if (keep && moved) {
3006
+ saveOrder();
3007
+ announceOrder(ended.id);
3008
+ }
3009
+ node.classList.add('settling');
3010
+ const settle = slideFrom(node, was);
3011
+ const done = () => {
3012
+ if (node.classList.contains('dragging')) return; // picked up again
3013
+ node.classList.remove('settling');
3014
+ node.style.transformOrigin = '';
3015
+ };
3016
+ if (settle) settle.finished.then(done, done);
3017
+ else done();
3018
+ }
3019
+
2785
3020
  // ---- terminal views -------------------------------------------------------
2786
3021
 
2787
3022
  const TERMINAL_THEME = {
@@ -3392,7 +3627,40 @@ $('changelog-restart').addEventListener('click', () => {
3392
3627
  closeChangelog();
3393
3628
  stopManager({ restart: true });
3394
3629
  });
3395
- addEventListener('storage', (e) => { if (e.key === CHANGELOG_SEEN_KEY) renderVersion(); });
3630
+ addEventListener('storage', (e) => {
3631
+ if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3632
+ // Another tab reordered the cards.
3633
+ else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3634
+ });
3635
+ $('sessions').addEventListener('pointerdown', (e) => {
3636
+ const handle = e.target.closest?.('.drag-handle');
3637
+ if (handle) startDrag(e, handle);
3638
+ });
3639
+ $('sessions').addEventListener('pointermove', (e) => {
3640
+ if (drag?.pointerId !== e.pointerId) return;
3641
+ // A mouse released outside the window sends no pointerup.
3642
+ if (e.pointerType === 'mouse' && !(e.buttons & 1)) return endDrag(true);
3643
+ drag.x = e.clientX;
3644
+ drag.y = e.clientY;
3645
+ scheduleDragFrame();
3646
+ });
3647
+ $('sessions').addEventListener('pointerup', (e) => { if (drag?.pointerId === e.pointerId) endDrag(true); });
3648
+ $('sessions').addEventListener('pointercancel', (e) => { if (drag?.pointerId === e.pointerId) endDrag(false); });
3649
+ // The loss from the last drop can arrive after a quick new pickup, which holds the capture again.
3650
+ $('sessions').addEventListener('lostpointercapture', (e) => {
3651
+ if (drag?.pointerId === e.pointerId && !drag.grid.hasPointerCapture(e.pointerId)) endDrag(true);
3652
+ });
3653
+ // A long press on the grip would open the context menu on a touch screen.
3654
+ $('sessions').addEventListener('contextmenu', (e) => { if (e.target.closest?.('.drag-handle')) e.preventDefault(); });
3655
+ // The page can move under a carried card without the pointer moving.
3656
+ addEventListener('scroll', scheduleDragFrame, { passive: true });
3657
+ addEventListener('resize', scheduleDragFrame);
3658
+ document.addEventListener('keydown', (e) => {
3659
+ if (!drag || e.key !== 'Escape') return;
3660
+ e.preventDefault();
3661
+ endDrag(false);
3662
+ });
3663
+ addEventListener('blur', () => endDrag(false));
3396
3664
  $('models-more').addEventListener('click', () => {
3397
3665
  const before = $('models-list').childElementCount;
3398
3666
  modelsView.all = true;
package/web/index.html CHANGED
@@ -62,6 +62,8 @@
62
62
  <h2 id="sessions-heading">Sessions <span id="session-count" class="count"></span></h2>
63
63
  </div>
64
64
  <div id="sessions" class="session-grid" role="list"></div>
65
+ <p id="reorder-status" class="visually-hidden" aria-live="polite"></p>
66
+ <p id="reorder-help" hidden>Drag to move the card, or press the arrow keys, Home or End.</p>
65
67
  <p id="empty" class="empty">No sessions yet. Pick a provider above to start one. Sessions keep running when you close this page.</p>
66
68
  </section>
67
69
 
@@ -346,7 +348,10 @@
346
348
  <div class="card-top">
347
349
  <span class="provider-icon"></span>
348
350
  <div class="card-title">
349
- <div class="name"></div>
351
+ <div class="name-row">
352
+ <div class="name"></div>
353
+ <button class="drag-handle" type="button" title="Drag to move this card, or use the arrow keys" aria-describedby="reorder-help"></button>
354
+ </div>
350
355
  <div class="meta"><span class="meta-text"></span><button class="session-id" type="button" hidden></button></div>
351
356
  </div>
352
357
  <button class="model-pill" type="button"></button>
@@ -147,6 +147,8 @@
147
147
  .session-card .card-top > .provider-icon { display: none; }
148
148
  .session-card .card-top { flex-wrap: wrap; gap: 6px 8px; }
149
149
  .session-card .card-title { flex-basis: 100%; }
150
+ /* The reorder grip steps out from under the corner sprig. */
151
+ .session-card .drag-handle { margin-right: 24px; }
150
152
 
151
153
  /* Working: ripples spread across still water beneath the spirit while fireflies rise around it. */
152
154
  .card-aura { position: absolute; z-index: -1; overflow: hidden; pointer-events: none; opacity: 0; transition: opacity 0.9s var(--ease); }
Binary file
Binary file
Binary file
Binary file
package/web/styles.css CHANGED
@@ -27,6 +27,7 @@
27
27
  --icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 5.5l6.5 6.5L9 18.5'/%3E%3C/svg%3E");
28
28
  --icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
29
29
  --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 10.8v6.2' stroke-width='2.4'/%3E%3Ccircle cx='12' cy='7.3' r='1.2' fill='black' stroke='none'/%3E%3C/svg%3E");
30
+ --icon-grip: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='9' cy='5.5' r='1.9'/%3E%3Ccircle cx='15' cy='5.5' r='1.9'/%3E%3Ccircle cx='9' cy='12' r='1.9'/%3E%3Ccircle cx='15' cy='12' r='1.9'/%3E%3Ccircle cx='9' cy='18.5' r='1.9'/%3E%3Ccircle cx='15' cy='18.5' r='1.9'/%3E%3C/svg%3E");
30
31
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
31
32
  font-size: 15px;
32
33
  color-scheme: light;
@@ -263,6 +264,40 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
263
264
  }
264
265
  .status-pill.exited { color: var(--danger); }
265
266
 
267
+ /* Reordering. The grip ends the name line without making it taller; its hit area grows for a finger. */
268
+ .name-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
269
+ .name-row .name { flex: 1; min-width: 0; }
270
+ .drag-handle {
271
+ position: relative; flex: none; align-self: stretch; display: grid; place-items: center; width: 24px; margin-right: -4px; padding: 0;
272
+ border: 0; border-radius: 6px; background: none; color: var(--muted); font: inherit; cursor: grab; opacity: 0.85;
273
+ touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent;
274
+ transition: opacity 0.15s, color 0.15s, background-color 0.15s;
275
+ }
276
+ .drag-handle::before { content: ""; width: 16px; height: 16px; background: currentColor; mask: var(--icon-grip) center / contain no-repeat; }
277
+ /* Grown up and sideways, never down over the id chip on the line below. */
278
+ .drag-handle::after { content: ""; position: absolute; inset: -20px -10px 0; }
279
+ .session-card:hover .drag-handle, .session-card:focus-within .drag-handle { opacity: 1; }
280
+ .drag-handle:hover { color: var(--text); background: color-mix(in srgb, var(--text) 9%, transparent); }
281
+ .drag-handle:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; opacity: 1; }
282
+ /* While a card is carried, the grid's own outline marks the place it will land. */
283
+ .session-grid { position: relative; }
284
+ .session-grid::before {
285
+ content: ""; position: absolute; left: 0; top: 0; width: var(--slot-w, 0); height: var(--slot-h, 0); translate: var(--slot-x, 0) var(--slot-y, 0);
286
+ border: 2px dashed color-mix(in srgb, var(--accent) 60%, transparent); border-radius: var(--radius);
287
+ background: color-mix(in srgb, var(--accent) 9%, transparent); opacity: 0; pointer-events: none; transition: opacity 0.2s var(--ease);
288
+ }
289
+ .session-grid.reordering::before { opacity: 1; transition: opacity 0.2s var(--ease), translate 0.24s var(--ease); }
290
+ .session-grid > .session-card.dragging, .session-grid > .session-card.settling { z-index: 4; transform: none; }
291
+ .session-grid > .session-card.dragging {
292
+ scale: 1.03; cursor: grabbing;
293
+ box-shadow: 0 24px 48px -14px rgb(0 0 0 / 0.5), 0 0 0 1.5px color-mix(in srgb, var(--accent) 60%, transparent);
294
+ transition: scale 0.18s var(--ease), box-shadow 0.18s var(--ease);
295
+ }
296
+ .session-grid > .session-card.settling { transition: scale 0.24s var(--ease), box-shadow 0.24s var(--ease); }
297
+ .session-grid > .session-card.dragging .drag-handle { opacity: 1; color: var(--text); cursor: grabbing; }
298
+ :root.reordering, :root.reordering * { cursor: grabbing; user-select: none; -webkit-user-select: none; }
299
+ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
300
+
266
301
  .agents-row { display: flex; align-items: center; gap: 10px; min-height: 30px; }
267
302
  .agents-label { font-size: 0.75rem; color: var(--muted); }
268
303
  .agents { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -637,6 +672,8 @@ a.news-link:visited { color: var(--muted); }
637
672
  .model-name::before, .provider .copies summary::before, .appearance::after, .btn, .models, .news-panel, .models::backdrop, .news-panel::backdrop { transition: none; }
638
673
  .github-waiting::before, body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after { animation: none; }
639
674
  .btn:active:not(:disabled) { transform: none; }
675
+ .drag-handle, .session-grid::before, .session-grid.reordering::before,
676
+ .session-grid > .session-card.dragging, .session-grid > .session-card.settling { transition: none; }
640
677
  }
641
678
 
642
679
  @media (max-width: 640px) {