dsh-better-workspace 0.35.0 → 0.36.7

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/src/client.js CHANGED
@@ -125,8 +125,6 @@ window.__ModuleLoader__.load({
125
125
  'settings.sessionLimit': "展开时显示会话数",
126
126
  'settings.sessionLimit.all': "全部",
127
127
  'settings.sessionLimit.hint': "展开工作区时先只显示最近的部分会话,其余收进「展开 {n} 个会话」一行,点击展开该组全部;置顶、进行中和等待你的会话始终显示。全部 = 不限量(默认)。",
128
- 'settings.sessionMenu': "会话右键菜单",
129
- 'settings.sessionMenu.hint': "开启时右键点击会话行即可打开操作菜单(包含官方的置顶、归档、停止并归档等);关闭后改为在行尾悬停显示官方样式的 ⋯ 按钮,点击打开同一个菜单。默认开启。",
130
128
  'settings.graft.title': "自动标题保留分组前缀",
131
129
  'settings.graft.hint': "「随对话更新的标题」实验功能会把会话改名成不带「/」分组的新名字,分组会因此丢失。开启后本插件视图保留原「/」前缀、只替换最后一段(如 A/xxx 自动变成 A/yyyy)。手动重命名不受影响。默认开启。",
132
130
  'settings.rowActions': "会话行悬停按钮",
@@ -303,8 +301,6 @@ window.__ModuleLoader__.load({
303
301
  'settings.sessionLimit': "Sessions shown when expanded",
304
302
  'settings.sessionLimit.all': "All",
305
303
  'settings.sessionLimit.hint': "An expanded workspace first shows only its most recent sessions, the rest behind one Show-more row; pinned, running and waiting-for-you sessions always stay visible. All = no limit (default).",
306
- 'settings.sessionMenu': "Session context menu",
307
- 'settings.sessionMenu.hint': "On: right-click a session row to open its action menu (including the official pin, archive, and stop-and-archive). Off: the row shows the official-style ⋯ button on hover instead, opening the same menu. On by default.",
308
304
  'settings.graft.title': "Auto titles keep the group prefix",
309
305
  'settings.graft.hint': "The 'titles that follow the conversation' experiment renames sessions to names without the '/' group, which dissolves the grouping. On, this view keeps the original '/' prefix and swaps only the last segment (A/xxx becomes A/yyyy). Manual renames are unaffected. On by default.",
310
306
  'settings.rowActions': "Session row hover buttons",
@@ -489,8 +485,6 @@ window.__ModuleLoader__.load({
489
485
  'settings.sessionLimit': "عدد الجلسات عند التوسيع",
490
486
  'settings.sessionLimit.all': "الكل",
491
487
  'settings.sessionLimit.hint': "تعرض مساحة العمل الموسعة أحدث الجلسات أولاً، والبقية خلف سطر عرض المزيد؛ الجلسات المثبتة والجارية والمنتظرة لك تبقى مرئية دائماً. الكل = بلا حد (افتراضي).",
492
- 'settings.sessionMenu': "قائمة النقر الأيمن للجلسة",
493
- 'settings.sessionMenu.hint': "عند التفعيل: انقر بزر الفأرة الأيمن على سطر الجلسة لفتح قائمة الإجراءات (بما في ذلك التثبيت والأرشفة الرسمية). عند الإيقاف: يظهر زر ⋯ بأسلوب رسمي عند تمرير المؤشر يفتح القائمة نفسها. مفعّل افتراضياً.",
494
488
  'settings.graft.title': "عناوين تلقائية تحتفظ ببادئة المجموعة",
495
489
  'settings.graft.hint': "تجربة 'العناوين التي تتبع المحادثة' تعيد تسمية الجلسات بأسماء بدون مجموعة '/'، فيختفي التقسيم. عند التفعيل يحتفظ هذا العرض بالبادئة الأصلية ويستبدل المقطع الأخير فقط (A/xxx يصبح A/yyyy). إعادة التسمية اليدوية لا تتأثر. مفعّل افتراضيًا.",
496
490
  'settings.rowActions': "أزرار تمرير سطر الجلسة",
@@ -667,8 +661,6 @@ window.__ModuleLoader__.load({
667
661
  'settings.sessionLimit': "Sitzungen beim Aufklappen",
668
662
  'settings.sessionLimit.all': "Alle",
669
663
  'settings.sessionLimit.hint': "Ein aufgeklappter Arbeitsbereich zeigt zuerst nur die neuesten Sitzungen, der Rest hinter einer Mehr-anzeigen-Zeile; angeheftete, laufende und auf Sie wartende Sitzungen bleiben immer sichtbar. Alle = unbegrenzt (Standard).",
670
- 'settings.sessionMenu': "Sitzungs-Kontextmenü",
671
- 'settings.sessionMenu.hint': "Ein: Rechtsklick auf eine Sitzungszeile öffnet das Aktionsmenü (inklusive offiziellem Anheften, Archivieren und Stoppen-und-Archivieren). Aus: beim Daraufzeigen erscheint stattdessen der offizielle ⋯-Knopf am Zeilenende und öffnet dasselbe Menü. Standardmäßig ein.",
672
664
  'settings.graft.title': "Automatische Titel behalten den Gruppenpräfix",
673
665
  'settings.graft.hint': "Das Experiment 'Titel, die der Unterhaltung folgen' benennt Sitzungen ohne '/'-Gruppe um, wodurch die Gruppierung verloren geht. Aktiv behält diese Ansicht den ursprünglichen Präfix und tauscht nur das letzte Segment (A/xxx wird A/yyyy). Manuelle Umbenennungen sind nicht betroffen. Standardmäßig aktiv.",
674
666
  'settings.rowActions': "Sitzungszeilen-Schnellknöpfe",
@@ -845,8 +837,6 @@ window.__ModuleLoader__.load({
845
837
  'settings.sessionLimit': "Sessions affichées à l’ouverture",
846
838
  'settings.sessionLimit.all': "Tout",
847
839
  'settings.sessionLimit.hint': "Un espace ouvert montre d’abord ses sessions les plus récentes, le reste derrière une ligne afficher-plus ; les sessions épinglées, en cours et en attente restent toujours visibles. Tout = sans limite (par défaut).",
848
- 'settings.sessionMenu': "Menu contextuel des sessions",
849
- 'settings.sessionMenu.hint': "Activé : un clic droit sur une ligne de session ouvre son menu d’actions (épinglage, archivage et arrêt-avec-archivage officiels inclus). Désactivé : la ligne affiche plutôt le bouton ⋯ officiel au survol, ouvrant le même menu. Activé par défaut.",
850
840
  'settings.graft.title': "Les titres automatiques gardent le préfixe de groupe",
851
841
  'settings.graft.hint': "L'expérience 'titres qui suivent la conversation' renomme les sessions sans le groupe '/', ce qui dissout le groupement. Activé, cette vue conserve le préfixe d'origine et ne remplace que le dernier segment (A/xxx devient A/yyyy). Les renommages manuels ne sont pas affectés. Activé par défaut.",
852
842
  'settings.rowActions': "Boutons de survol des sessions",
@@ -1023,8 +1013,6 @@ window.__ModuleLoader__.load({
1023
1013
  'settings.sessionLimit': "विस्तार पर दिखने वाले सेशन",
1024
1014
  'settings.sessionLimit.all': "सभी",
1025
1015
  'settings.sessionLimit.hint': "विस्तारित वर्कस्पेस पहले केवल हाल के सेशन दिखाता है, बाकी एक और-दिखाएँ पंक्ति के पीछे; पिन, चल रहे और आपकी प्रतीक्षा में सेशन सदा दिखते हैं। सभी = असीमित (डिफ़ॉल्ट)।",
1026
- 'settings.sessionMenu': "सेशन संदर्भ मेनू",
1027
- 'settings.sessionMenu.hint': "चालू: सेशन पंक्ति पर राइट-क्लिक करने से उसका क्रिया मेनू खुलता है (आधिकारिक पिन, आर्काइव और रोककर-आर्काइव सहित)। बंद: पंक्ति पर होवर करने पर आधिकारिक शैली का ⋯ बटन दिखता है, जो वही मेनू खोलता है। डिफ़ॉल्ट रूप से चालू।",
1028
1016
  'settings.graft.title': "स्वतः शीर्षक समूह उपसर्ग रखते हैं",
1029
1017
  'settings.graft.hint': "'बातचीत के अनुसार शीर्षक' प्रयोग सत्रों का नाम बिना '/' समूह के बदल देता है, जिससे समूहन टूट जाता है। चालू होने पर यह दृश्य मूल उपसर्ग रखता है और केवल अंतिम खंड बदलता है (A/xxx → A/yyyy)। मैन्युअल नाम बदलना प्रभावित नहीं होता। डिफ़ॉल्ट रूप से चालू।",
1030
1018
  'settings.rowActions': "सेशन पंक्ति होवर बटन",
@@ -1201,8 +1189,6 @@ window.__ModuleLoader__.load({
1201
1189
  'settings.sessionLimit': "Sesi saat dibuka",
1202
1190
  'settings.sessionLimit.all': "Semua",
1203
1191
  'settings.sessionLimit.hint': "Ruang kerja yang dibuka pertama menampilkan sesi terbaru, sisanya di balik satu baris tampilkan-lebih; sesi pin, berjalan, dan menunggu Anda selalu terlihat. Semua = tanpa batas (bawaan).",
1204
- 'settings.sessionMenu': "Menu klik kanan sesi",
1205
- 'settings.sessionMenu.hint': "Aktif: klik kanan pada baris sesi untuk membuka menu aksinya (termasuk pin, arsip, dan henti-dan-arsip resmi). Nonaktif: baris menampilkan tombol ⋯ gaya resmi saat dihover, membuka menu yang sama. Aktif secara bawaan.",
1206
1192
  'settings.graft.title': "Judul otomatis mempertahankan awalan grup",
1207
1193
  'settings.graft.hint': "Eksperimen 'judul yang mengikuti percakapan' mengganti nama sesi tanpa grup '/', sehingga pengelompokan hilang. Saat aktif, tampilan ini mempertahankan awalan asli dan hanya mengganti segmen terakhir (A/xxx menjadi A/yyyy). Penggantian nama manual tidak terpengaruh. Aktif secara bawaan.",
1208
1194
  'settings.rowActions': "Tombol hover baris sesi",
@@ -1379,8 +1365,6 @@ window.__ModuleLoader__.load({
1379
1365
  'settings.sessionLimit': "Sessioni mostrate all’apertura",
1380
1366
  'settings.sessionLimit.all': "Tutte",
1381
1367
  'settings.sessionLimit.hint': "Un’area aperta mostra prima le sessioni più recenti, il resto dietro una riga mostra-altro; sessioni fissate, in corso e in attesa restano sempre visibili. Tutte = senza limiti (predefinito).",
1382
- 'settings.sessionMenu': "Menu contestuale sessione",
1383
- 'settings.sessionMenu.hint': "Attivo: il clic destro su una riga di sessione apre il menu azioni (con pin, archiviazione e ferma-e-archivia ufficiali). Disattivo: la riga mostra il pulsante ⋯ ufficiale al passaggio, aprendo lo stesso menu. Attivo per impostazione predefinita.",
1384
1368
  'settings.graft.title': "I titoli automatici conservano il prefisso del gruppo",
1385
1369
  'settings.graft.hint': "L'esperimento 'titoli che seguono la conversazione' rinomina le sessioni senza il gruppo '/', sciogliendo il raggruppamento. Attivo, questa vista conserva il prefisso originale e sostituisce solo l'ultimo segmento (A/xxx diventa A/yyyy). Le rinomine manuali non sono influenzate. Attivo per impostazione predefinita.",
1386
1370
  'settings.rowActions': "Pulsanti al passaggio della sessione",
@@ -1557,8 +1541,6 @@ window.__ModuleLoader__.load({
1557
1541
  'settings.sessionLimit': "展開時に表示するセッション数",
1558
1542
  'settings.sessionLimit.all': "すべて",
1559
1543
  'settings.sessionLimit.hint': "展開したワークスペースは最近のセッションを先に表示し、残りは「さらに表示」行の後ろに収まります。ピン留め・実行中・対応待ちのセッションは常に表示。すべて = 無制限(既定)。",
1560
- 'settings.sessionMenu': "セッション右クリックメニュー",
1561
- 'settings.sessionMenu.hint': "オン:セッション行を右クリックすると操作メニューを開きます(公式のピン留め・アーカイブ・停止してアーカイブを含む)。オフ:行にカーソルを合わせると公式スタイルの ⋯ ボタンが表示され、同じメニューを開きます。既定はオン。",
1562
1544
  'settings.graft.title': "自動タイトルはグループ接頭辞を保持",
1563
1545
  'settings.graft.hint': "「会話に合わせて更新されるタイトル」実験機能は「/」グループなしの名前にセッションを改名するため、グルーピングが失われます。オンにすると、このビューは元の「/」接頭辞を保持し最終セグメントだけを差し替えます(A/xxx → A/yyyy)。手動リネームには影響しません。デフォルトでオン。",
1564
1546
  'settings.rowActions': "セッション行のホバーボタン",
@@ -1735,8 +1717,6 @@ window.__ModuleLoader__.load({
1735
1717
  'settings.sessionLimit': "펼칠 때 표시할 세션",
1736
1718
  'settings.sessionLimit.all': "전체",
1737
1719
  'settings.sessionLimit.hint': "펼친 작업 공간은 최근 세션을 먼저 보여주고 나머지는 더보기 행 뒤에 둡니다. 고정, 실행 중, 대기 중인 세션은 항상 표시됩니다. 전체 = 제한 없음(기본값).",
1738
- 'settings.sessionMenu': "세션 우클릭 메뉴",
1739
- 'settings.sessionMenu.hint': "켬: 세션 행을 우클릭하면 작업 메뉴가 열립니다(공식 고정, 보관, 중지 후 보관 포함). 끔: 행에 마우스를 올리면 공식 스타일의 ⋯ 버튼이 표시되어 같은 메뉴를 엽니다. 기본값은 켬.",
1740
1720
  'settings.graft.title': "자동 제목은 그룹 접두어를 유지",
1741
1721
  'settings.graft.hint': "'대화에 따라 업데이트되는 제목' 실험 기능이 '/' 그룹 없는 이름으로 세션 이름을 바꿔 그룹핑이 사라집니다. 켜면 이 뷰는 원래 '/' 접두어를 유지하고 마지막 세그먼트만 교체합니다(A/xxx → A/yyyy). 수동 이름 바꾸기는 영향을 받지 않습니다. 기본적으로 켬.",
1742
1722
  'settings.rowActions': "세션 행 호버 버튼",
@@ -1913,8 +1893,6 @@ window.__ModuleLoader__.load({
1913
1893
  'settings.sessionLimit': "Sessies bij uitklappen",
1914
1894
  'settings.sessionLimit.all': "Alles",
1915
1895
  'settings.sessionLimit.hint': "Een uitgeklapte werkruimte toont eerst de nieuwste sessies, de rest achter een toon-meer rij; vastgemaakte, lopende en op u wachtende sessies blijven altijd zichtbaar. Alles = onbeperkt (standaard).",
1916
- 'settings.sessionMenu': "Sessie-contextmenu",
1917
- 'settings.sessionMenu.hint': "Aan: rechtermuisklik op een sessierij opent het actiemenu (inclusief officieel vastmaken, archiveren en stoppen-en-archiveren). Uit: bij aanwijzen verschijnt in plaats daarvan de officiële ⋯-knop aan het rij-einde, die hetzelfde menu opent. Standaard aan.",
1918
1896
  'settings.graft.title': "Automatische titels behouden het groepsprefix",
1919
1897
  'settings.graft.hint': "Het experiment 'titels die het gesprek volgen' hernoemt sessies naar namen zonder de '/'-groep, waardoor de groepering verloren gaat. Aan: deze weergave behoudt het oorspronkelijke prefix en vervangt alleen het laatste segment (A/xxx wordt A/yyyy). Handmatige hernoemingen zijn niet getroffen. Standaard aan.",
1920
1898
  'settings.rowActions': "Sessierij-snelknoppen",
@@ -2091,8 +2069,6 @@ window.__ModuleLoader__.load({
2091
2069
  'settings.sessionLimit': "Sesje po rozwinięciu",
2092
2070
  'settings.sessionLimit.all': "Wszystkie",
2093
2071
  'settings.sessionLimit.hint': "Rozwinięty obszar pokazuje najpierw najnowsze sesje, reszta czeka za wierszem pokaż-więcej; przypięte, uruchomione i czekające sesje są zawsze widoczne. Wszystkie = bez limitu (domyślnie).",
2094
- 'settings.sessionMenu': "Menu kontekstowe sesji",
2095
- 'settings.sessionMenu.hint': "Włączone: kliknięcie prawym przyciskiem wiersza sesji otwiera jego menu akcji (w tym oficjalne przypinanie, archiwizację i zatrzymanie z archiwizacją). Wyłączone: przy najechaniu wiersz pokazuje oficjalny przycisk ⋯ otwierający to samo menu. Domyślnie włączone.",
2096
2072
  'settings.graft.title': "Automatyczne tytuły zachowują prefiks grupy",
2097
2073
  'settings.graft.hint': "Eksperyment 'tytuły podążające za rozmową' zmienia nazwy sesji na bez grupy '/', co rozbija grupowanie. Włączone: ten widok zachowuje oryginalny prefiks i zamienia tylko ostatni segment (A/xxx staje się A/yyyy). Ręczne zmiany nazwy nie są objęte. Domyślnie włączone.",
2098
2074
  'settings.rowActions': "Przyciski najechania wiersza sesji",
@@ -2269,8 +2245,6 @@ window.__ModuleLoader__.load({
2269
2245
  'settings.sessionLimit': "Sessões ao expandir",
2270
2246
  'settings.sessionLimit.all': "Todas",
2271
2247
  'settings.sessionLimit.hint': "Um espaço expandido mostra primeiro as sessões mais recentes, o restante atrás de uma linha mostrar-mais; sessões fixadas, em execução e aguardando você permanecem visíveis. Todas = sem limite (predefinição).",
2272
- 'settings.sessionMenu': "Menu de contexto da sessão",
2273
- 'settings.sessionMenu.hint': "Ligado: clicar com o botão direito numa linha de sessão abre o menu de ações (incluindo fixar, arquivar e parar-e-arquivar oficiais). Desligado: a linha mostra o botão ⋯ oficial ao passar o cursor, abrindo o mesmo menu. Ligado por predefinição.",
2274
2248
  'settings.graft.title': "Títulos automáticos mantêm o prefixo do grupo",
2275
2249
  'settings.graft.hint': "O experimento 'títulos que acompanham a conversa' renomeia sessões sem o grupo '/', dissolvendo o agrupamento. Ativo: esta visão mantém o prefixo original e troca apenas o último segmento (A/xxx vira A/yyyy). Renomeações manuais não são afetadas. Ativo por padrão.",
2276
2250
  'settings.rowActions': "Botões de passagem da sessão",
@@ -2447,8 +2421,6 @@ window.__ModuleLoader__.load({
2447
2421
  'settings.sessionLimit': "Сессий при разворачивании",
2448
2422
  'settings.sessionLimit.all': "Все",
2449
2423
  'settings.sessionLimit.hint': "Развёрнутая область сначала показывает последние сессии, остальные — за строкой «показать ещё»; закреплённые, идущие и ожидающие вас сессии видны всегда. Все = без ограничения (по умолчанию).",
2450
- 'settings.sessionMenu': "Контекстное меню сессии",
2451
- 'settings.sessionMenu.hint': "Вкл: правый клик по строке сессии открывает меню действий (включая официальные закрепление, архивацию и «остановить и архивировать»). Выкл: при наведении в строке появляется официальная кнопка ⋯, открывающая то же меню. Включено по умолчанию.",
2452
2424
  'settings.graft.title': "Автозаголовки сохраняют префикс группы",
2453
2425
  'settings.graft.hint': "Эксперимент «заголовки, следующие за беседой» переименовывает сессии в имена без группы «/», из-за чего группировка теряется. Включено: этот вид сохраняет исходный префикс «/» и заменяет только последний сегмент (A/xxx становится A/yyyy). Ручные переименования не затрагиваются. Включено по умолчанию.",
2454
2426
  'settings.rowActions': "Кнопки наведения строки сессии",
@@ -2625,8 +2597,6 @@ window.__ModuleLoader__.load({
2625
2597
  'settings.sessionLimit': "Sessioner vid utfällning",
2626
2598
  'settings.sessionLimit.all': "Alla",
2627
2599
  'settings.sessionLimit.hint': "En utfälld arbetsyta visar först de senaste sessionerna, resten bakom en visa-mer-rad; fästa, pågående och väntande sessioner syns alltid. Alla = obegränsat (standard).",
2628
- 'settings.sessionMenu': "Sessionens högerklicksmeny",
2629
- 'settings.sessionMenu.hint': "På: högerklick på en sessionsrad öppnar dess åtgärdsmeny (inklusive officiell fästning, arkivering och stoppa-och-arkivera). Av: raden visar istället den officiella ⋯-knappen vid hovring, som öppnar samma meny. På som standard.",
2630
2600
  'settings.graft.title': "Automatiska titlar behåller gruppprefixet",
2631
2601
  'settings.graft.hint': "Experimentet 'titlar som följer samtalet' döper om sessioner till namn utan '/'-gruppen, vilket löser upp grupperingen. På: den här vyn behåller det ursprungliga prefixet och byter bara ut sista segmentet (A/xxx blir A/yyyy). Manuella namnbyten påverkas inte. På som standard.",
2632
2602
  'settings.rowActions': "Hovringsknappar för sessionsraden",
@@ -2803,8 +2773,6 @@ window.__ModuleLoader__.load({
2803
2773
  'settings.sessionLimit': "จำนวนเซสชันเมื่อกาง",
2804
2774
  'settings.sessionLimit.all': "ทั้งหมด",
2805
2775
  'settings.sessionLimit.hint': "พื้นที่ที่กางจะแสดงเซสชันล่าสุดก่อน ส่วนที่เหลืออยู่หลังแถวแสดงเพิ่ม เซสชันที่ปักหมุด กำลังทำงาน และรอคุณจะแสดงตลอด ทั้งหมด = ไม่จำกัด (ค่าเริ่มต้น)",
2806
- 'settings.sessionMenu': "เมนูคลิกขวาของเซสชัน",
2807
- 'settings.sessionMenu.hint': "เปิด: คลิกขวาที่แถวเซสชันเพื่อเปิดเมนูการกระทำ (รวมปักหมุด, เก็บถาวร และหยุด-แล้ว-เก็บถาวรแบบทางการ) ปิด: เมื่อชี้ที่แถวจะแสดงปุ่ม ⋯ สไตล์ทางการเปิดเมนูเดียวกันแทน เปิดตามค่าเริ่มต้น",
2808
2776
  'settings.graft.title': "ชื่ออัตโนมัติคงคำนำหน้ากลุ่ม",
2809
2777
  'settings.graft.hint': "ฟีเจอร์ทดลอง 'ชื่อที่ตามการสนทนา' เปลี่ยนชื่อเซสชันเป็นชื่อที่ไม่มีกลุ่ม '/' ทำให้การจัดกลุ่มหายไป เมื่อเปิด มุมมองนี้จะคงคำนำหน้าเดิมและเปลี่ยนเฉพาะส่วนท้าย (A/xxx กลายเป็น A/yyyy) การเปลี่ยนชื่อด้วยมือไม่ได้รับผลกระทบ เปิดตามค่าเริ่มต้น",
2810
2778
  'settings.rowActions': "ปุ่มเมื่อชี้แถวเซสชัน",
@@ -2981,8 +2949,6 @@ window.__ModuleLoader__.load({
2981
2949
  'settings.sessionLimit': "Açıldığında gösterilecek oturum",
2982
2950
  'settings.sessionLimit.all': "Tümü",
2983
2951
  'settings.sessionLimit.hint': "Açılan çalışma alanı önce en yeni oturumları gösterir, kalanı bir daha-göster satırının ardındadır; sabitlenmiş, çalışan ve sizi bekleyen oturumlar her zaman görünür. Tümü = sınırsız (varsayılan).",
2984
- 'settings.sessionMenu': "Oturum sağ tık menüsü",
2985
- 'settings.sessionMenu.hint': "Açık: oturum satırına sağ tıklayınca eylem menüsü açılır (resmî sabitleme, arşivleme ve durdurup-arşivleme dahil). Kapalı: satırın üzerine gelince resmî ⋯ düğmesi görünür ve aynı menüyü açar. Varsayılan açık.",
2986
2952
  'settings.graft.title': "Otomatik başlıklar grup önekini korur",
2987
2953
  'settings.graft.hint': "Deneme özelliği 'konuşmayı izleyen başlıklar' oturumları '/' grubu olmadan yeniden adlandırır, bu da gruplamayı çözer. Açıkken bu görünüm özgün öneki korur ve yalnızca son bölümü değiştirir (A/xxx, A/yyyy olur). Manuel yeniden adlandırmalar etkilenmez. Varsayılan olarak açık.",
2988
2954
  'settings.rowActions': "Oturum satırı düğmeleri",
@@ -3159,8 +3125,6 @@ window.__ModuleLoader__.load({
3159
3125
  'settings.sessionLimit': "Số phiên khi mở rộng",
3160
3126
  'settings.sessionLimit.all': "Tất cả",
3161
3127
  'settings.sessionLimit.hint': "Không gian được mở rộng sẽ hiện các phiên gần đây trước, phần còn lại nằm sau một hàng xem-thêm; phiên ghim, đang chạy và đang chờ bạn luôn hiển thị. Tất cả = không giới hạn (mặc định).",
3162
- 'settings.sessionMenu': "Menu chuột phải phiên",
3163
- 'settings.sessionMenu.hint': "Bật: nhấp chuột phải vào dòng phiên để mở menu thao tác (bao gồm ghim, lưu trữ và dừng-rồi-lưu trữ chính thức). Tắt: dòng hiển thị nút ⋯ kiểu chính thức khi di chuột, mở cùng menu đó. Bật theo mặc định.",
3164
3128
  'settings.graft.title': "Tiêu đề tự động giữ tiền tố nhóm",
3165
3129
  'settings.graft.hint': "Thử nghiệm 'tiêu đề cập nhật theo hội thoại' đổi tên phiên thành tên không có nhóm '/', làm mất phân nhóm. Bật: khung nhìn này giữ tiền tố gốc và chỉ thay đoạn cuối (A/xxx thành A/yyyy). Đổi tên thủ công không bị ảnh hưởng. Bật theo mặc định.",
3166
3130
  'settings.rowActions': "Nút hover dòng phiên",
@@ -3337,8 +3301,6 @@ window.__ModuleLoader__.load({
3337
3301
  'settings.sessionLimit': "展開時顯示會話數",
3338
3302
  'settings.sessionLimit.all': "全部",
3339
3303
  'settings.sessionLimit.hint': "展開工作區時先只顯示最近的部分會話,其餘收進「展開 {n} 個會話」一行,點擊展開該組全部;置頂、進行中和等待你的會話始終顯示。全部 = 不限量(預設)。",
3340
- 'settings.sessionMenu': "會話右鍵選單",
3341
- 'settings.sessionMenu.hint': "開啟時右鍵點擊會話行即可開啟操作選單(包含官方的置頂、封存、停止並封存等);關閉後改為在行尾懸停顯示官方樣式的 ⋯ 按鈕,點擊開啟同一個選單。預設開啟。",
3342
3304
  'settings.graft.title': "自動標題保留分組前綴",
3343
3305
  'settings.graft.hint': "「隨對話更新的標題」實驗功能會把會話改名成不帶「/」分組的新名字,分組會因此丟失。開啟後本插件視圖保留原「/」前綴、只替換最後一段(如 A/xxx 自動變成 A/yyyy)。手動重新命名不受影響。預設開啟。",
3344
3306
  'settings.rowActions': "會話行懸停按鈕",
@@ -3515,8 +3477,6 @@ window.__ModuleLoader__.load({
3515
3477
  'settings.sessionLimit': "展開時顯示會話數",
3516
3478
  'settings.sessionLimit.all': "全部",
3517
3479
  'settings.sessionLimit.hint': "展開工作區時先只顯示最近的部分會話,其餘收進「展開 {n} 個會話」一行,點擊展開該組全部;置頂、進行中和等待你的會話始終顯示。全部 = 不限量(預設)。",
3518
- 'settings.sessionMenu': "會話右鍵選單",
3519
- 'settings.sessionMenu.hint': "開啟時右鍵點擊會話行即可開啟操作選單(包含官方的置頂、歸檔、停止並歸檔等);關閉後改為在行尾懸停顯示官方樣式的 ⋯ 按鈕,點擊開啟同一個選單。預設開啟。",
3520
3480
  'settings.graft.title': "自動標題保留分組前綴",
3521
3481
  'settings.graft.hint': "「隨對話更新的標題」實驗功能會把會話改名成不帶「/」分組的新名字,分組會因此丟失。開啟後本外掛檢視保留原「/」前綴、只替換最後一段(如 A/xxx 自動變成 A/yyyy)。手動重新命名不受影響。預設開啟。",
3522
3482
  'settings.rowActions': "會話行懸停按鈕",
@@ -3693,8 +3653,6 @@ window.__ModuleLoader__.load({
3693
3653
  'settings.sessionLimit': "展開時顯示會話數",
3694
3654
  'settings.sessionLimit.all': "全部",
3695
3655
  'settings.sessionLimit.hint': "展開工作區時先只顯示最近的部分會話,其餘收進「展開 {n} 個會話」一行,點擊展開該組全部;釘選、進行中和等待你的會話始終顯示。全部 = 不限量(預設)。",
3696
- 'settings.sessionMenu': "會話右鍵選單",
3697
- 'settings.sessionMenu.hint': "開啟時右鍵點擊會話列即可開啟動作選單(包含官方的釘選、封存、停止並封存等);關閉後改為在列尾懸停顯示官方樣式的 ⋯ 按鈕,點擊開啟同一個選單。預設開啟。",
3698
3656
  'settings.graft.title': "自動標題保留分組前綴",
3699
3657
  'settings.graft.hint': "「隨對話更新的標題」實驗功能會把會話改名成不帶「/」分組的新名字,分組會因此丟失。開啟後本插件檢視保留原「/」前綴、只替換最後一段(如 A/xxx 自動變成 A/yyyy)。手動重新命名不受影響。預設開啟。",
3700
3658
  'settings.rowActions': "會話列懸停按鈕",
@@ -4527,7 +4485,7 @@ window.__ModuleLoader__.load({
4527
4485
  return out
4528
4486
  }
4529
4487
 
4530
- function buildTree(items, groupMode, explicitWs) {
4488
+ function buildTree(items, groupMode, explicitWs, folderOrder) {
4531
4489
  const list = items || []
4532
4490
  // Issue #7: 'disk' nests by disk only, 'disk-slash' adds the name
4533
4491
  // layer on top (the historical workspaceSlash-on shape), 'slash' runs
@@ -4606,11 +4564,76 @@ window.__ModuleLoader__.load({
4606
4564
  }
4607
4565
  ensure(segs.slice(0, -1)).workspaces.push(entry)
4608
4566
  }
4609
- const sortRec = (node) => {
4610
- node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
4611
- for (const child of node.folders) sortRec(child)
4567
+ // Group order: the NAME sort is only the seed. v0.36.4 keeps a
4568
+ // persisted per-parent name list (folderOrder) so a dragged group
4569
+ // keeps its place — before this the drop committed the registry
4570
+ // order but the renderer re-sorted by name, which is why dragging a
4571
+ // group row looked like a no-op. Names absent from the stored list
4572
+ // (a just-created group) sort after the known ones by locale.
4573
+ // v0.36.5: the SAME list may also carry "w:<workspaceId>" tokens,
4574
+ // which interleave a BARE workspace row with the group rows of its
4575
+ // level. Before this a level always rendered every folder first and
4576
+ // every workspace after, so a top-level workspace could never be
4577
+ // dropped "as the first row" — the probe showed the commit landing
4578
+ // but the row staying at the bottom ("插到第一个永远失败").
4579
+ // Tokens absent from the list (a new member, a workspace that just
4580
+ // moved in) append in the default order: groups first, then
4581
+ // workspaces in registry order.
4582
+ const orderOf = (parentKey) => {
4583
+ if (!folderOrder || typeof folderOrder !== 'object') return null
4584
+ const list = folderOrder[parentKey]
4585
+ return Array.isArray(list) ? list : null
4612
4586
  }
4613
- sortRec(root)
4587
+ const sortRec = (node, parentKey) => {
4588
+ const order = orderOf(parentKey)
4589
+ const names = order
4590
+ ? order.filter((token) => typeof token === 'string' && token !== '' && !token.startsWith('w:'))
4591
+ : null
4592
+ if (names && names.length) {
4593
+ const rank = new Map()
4594
+ for (let i = 0; i < names.length; i += 1) rank.set(names[i], i)
4595
+ node.folders.sort((a, b) => {
4596
+ const ra = rank.has(a.name) ? rank.get(a.name) : names.length
4597
+ const rb = rank.has(b.name) ? rank.get(b.name) : names.length
4598
+ if (ra !== rb) return ra - rb
4599
+ return a.name.localeCompare(b.name, 'zh')
4600
+ })
4601
+ } else {
4602
+ node.folders.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
4603
+ }
4604
+ // Level identity every workspace row carries into its drag payload:
4605
+ // the mixed reorder is siblings-only, so the drop gate compares it
4606
+ // with parentKeyOf(target group idPath).
4607
+ for (const w of node.workspaces) w.levelKey = parentKey
4608
+ // Mixed display sequence of THIS level: the persisted order first
4609
+ // (groups and bare workspaces interleaved), then whatever the list
4610
+ // does not mention, groups before workspaces.
4611
+ const folderByName = new Map(node.folders.map((child) => [child.name, child]))
4612
+ const wsById = new Map(node.workspaces.map((w) => [w.workspaceId, w]))
4613
+ const seq = []
4614
+ const seenFolders = new Set()
4615
+ const seenWorkspaces = new Set()
4616
+ for (const token of order || []) {
4617
+ if (typeof token !== 'string' || token === '') continue
4618
+ if (token.startsWith('w:')) {
4619
+ const id = token.slice(2)
4620
+ const entry = wsById.get(id)
4621
+ if (!entry || seenWorkspaces.has(id)) continue
4622
+ seq.push({ kind: 'ws', entry })
4623
+ seenWorkspaces.add(id)
4624
+ } else {
4625
+ const child = folderByName.get(token)
4626
+ if (!child || seenFolders.has(token)) continue
4627
+ seq.push({ kind: 'folder', node: child })
4628
+ seenFolders.add(token)
4629
+ }
4630
+ }
4631
+ for (const child of node.folders) if (!seenFolders.has(child.name)) seq.push({ kind: 'folder', node: child })
4632
+ for (const w of node.workspaces) if (!seenWorkspaces.has(w.workspaceId)) seq.push({ kind: 'ws', entry: w })
4633
+ node.seq = seq
4634
+ for (const child of node.folders) sortRec(child, child.idPath)
4635
+ }
4636
+ sortRec(root, idPrefix)
4614
4637
  return root
4615
4638
  }
4616
4639
  const topLevel = list.filter((w) => diskParentOf.get(w.workspaceId) === undefined)
@@ -4715,7 +4738,7 @@ window.__ModuleLoader__.load({
4715
4738
  '.bw-rows-exits{position:fixed;inset:0;contain:strict;overflow:clip;pointer-events:none}',
4716
4739
  '.bw-tree[data-bw-density="compact"]{--bw-row-h:28px;--bw-session-h:26px}',
4717
4740
  '.bw-tree[data-bw-density="roomy"]{--bw-row-h:40px;--bw-session-h:38px}',
4718
- '.bw-row{display:flex;align-items:center;gap:6px;height:var(--bw-row-h,34px);padding:0 8px;padding-inline-start:calc(8px + var(--bw-indent,0px));border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative}',
4741
+ '.bw-row{display:flex;align-items:center;gap:6px;height:var(--bw-row-h,34px);padding:0 8px;padding-inline-start:calc(8px + var(--bw-indent,0px));border-radius:var(--dsw-radius-md,8px);cursor:pointer;user-select:none;font-size:14px;line-height:20px;color:var(--dsw-alias-label-primary,#e6e6e6);position:relative;transition:height .15s ease-out,padding .15s ease-out}',
4719
4742
  '.bw-drop-before::before{content:"";position:absolute;z-index:1;left:0;right:4px;top:-7px;height:12px;pointer-events:none;background:linear-gradient(55deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 0/5px 7px no-repeat,linear-gradient(125deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 5px/5px 7px no-repeat,linear-gradient(var(--dsw-alias-state-business-primary,#5b8def),var(--dsw-alias-state-business-primary,#5b8def)) 4px 5px/calc(100% - 4px) 2px no-repeat}',
4720
4743
  '.bw-drop-after::after{content:"";position:absolute;z-index:1;left:0;right:4px;bottom:-7px;height:12px;pointer-events:none;background:linear-gradient(55deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 0/5px 7px no-repeat,linear-gradient(125deg,transparent 0,var(--dsw-alias-state-business-primary,#5b8def) 50%,transparent 100%) 0 5px/5px 7px no-repeat,linear-gradient(var(--dsw-alias-state-business-primary,#5b8def),var(--dsw-alias-state-business-primary,#5b8def)) 4px 5px/calc(100% - 4px) 2px no-repeat}',
4721
4744
  '.bw-drop-into{outline:1.5px dashed var(--dsw-alias-brand-primary,#5b8def);outline-offset:-1.5px}',
@@ -4723,6 +4746,15 @@ window.__ModuleLoader__.load({
4723
4746
  // rim + a tinted wash so "this drop joins a group" reads stronger than
4724
4747
  // the reorder dashes above.
4725
4748
  '.bw-drop-into-strong{outline:2px solid var(--dsw-alias-brand-primary,#5b8def);outline-offset:-2px;background:color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 16%, transparent)}',
4749
+ // v0.36.1 edge-band reorder slot on group rows: the row SPLITS OPEN to
4750
+ // twice its height so "a row will land here" is visible without
4751
+ // staring for the thin official line. Both the height and the content
4752
+ // slide are transitioned (padding, which CAN animate — align-items
4753
+ // cannot, it teleports the content and feels janky), so the row
4754
+ // opens/closes like a drawer, not a flash. The gap fills with a faint
4755
+ // brand wash; the middle half of the row (drop-into) never opens.
4756
+ '.bw-slot-above{padding-top:var(--bw-row-h,34px);background:linear-gradient(to bottom, color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 10%, transparent) 0, transparent calc(var(--bw-row-h,34px)))}',
4757
+ '.bw-slot-below{padding-bottom:var(--bw-row-h,34px);background:linear-gradient(to top, color-mix(in srgb, var(--dsw-alias-brand-primary,#5b8def) 10%, transparent) 0, transparent calc(var(--bw-row-h,34px)))}',
4726
4758
  // Session drag whose source sits in a group: a quiet dashed line at the
4727
4759
  // bottom of the tree says "anywhere outside a group = out of it". Kept
4728
4760
  // faint on purpose — the row outlines are the loud feedback.
@@ -5020,7 +5052,7 @@ window.__ModuleLoader__.load({
5020
5052
  // LITERAL, not a spread of DEFAULT_APPEARANCE: init() is eval-harness
5021
5053
  // friendly (no cross-section reference), and the smoke suite asserts the
5022
5054
  // two stay in lockstep field by field.
5023
- init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} } }),
5055
+ init: () => ({ expanded: {}, sessionsExpanded: {}, sessionGroups: {}, sessionOrder: {}, prefs: { compactChains: true, sessionLimit: 0, appearance: { color: '', glow: 0, weight: 0, shadow: false, stroke: false, strokeWidth: 1, strokeColor: '#808080' } }, styling: {}, groupBy: 'workspace-tree', orderBy: 'manual', archivedFilter: 'default', workspaceGroupMode: 'disk', sessionTitleSlash: true, workspaceTitleSlash: false, ungroupedOpen: true, folders: { ws: {}, sess: {} }, folderOrder: {} }),
5024
5056
  // NOTE: hydration REPLACES the state with the persisted whole value —
5025
5057
  // init defaults never merge. Every action must tolerate a missing key
5026
5058
  // (states persisted by older plugin versions lack sessionGroups), and
@@ -5080,6 +5112,19 @@ window.__ModuleLoader__.load({
5080
5112
  },
5081
5113
  // v0.21.0 collapsible Ungrouped block (issue #6).
5082
5114
  setUngroupedOpen: (d, value) => { d.ungroupedOpen = value },
5115
+ // Persisted GROUP order (v0.36.4), one name list per PARENT identity
5116
+ // (a level's idPrefix for the root level, else the parent group's
5117
+ // idPath). Only sibling reorders are written here — a cross-parent
5118
+ // move is a rename, not an order change, and stays refused in the
5119
+ // drop handler. An empty list deletes the entry so the name sort
5120
+ // comes back for that parent.
5121
+ setFolderOrder: (d, parentKey, names) => {
5122
+ if (!d.folderOrder || typeof d.folderOrder !== 'object') d.folderOrder = {}
5123
+ const key = String(parentKey == null ? '' : parentKey)
5124
+ const clean = (Array.isArray(names) ? names : []).map((n) => String(n)).filter((n) => n !== '')
5125
+ if (clean.length === 0) delete d.folderOrder[key]
5126
+ else d.folderOrder[key] = clean
5127
+ },
5083
5128
  // Explicit (possibly EMPTY) groups, v0.17.0. A group has always been a
5084
5129
  // projection of "/" in member titles, so a group with no members simply
5085
5130
  // did not exist and "create a group" had nothing to write. These two
@@ -6019,7 +6064,7 @@ window.__ModuleLoader__.load({
6019
6064
  }
6020
6065
  }
6021
6066
 
6022
- function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
6067
+ function FolderRow({ node, depth, expanded, onToggle, onContextMenu, dropInto, dropSlot, dragEvents, custStyle, iconMode, pulse, active, dataRowKey, t }) {
6023
6068
  const total = countWorkspaces(node)
6024
6069
  const iconEl = iconOf(iconMode, expanded)
6025
6070
  // Hidden status dots breathe on the icon; with the icon hidden (mode
@@ -6028,7 +6073,14 @@ window.__ModuleLoader__.load({
6028
6073
  ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
6029
6074
  : iconEl
6030
6075
  return E('div', {
6031
- className: cls('bw-row', 'bw-container-row', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6076
+ // v0.36.4: an edge band opens the EMPTY reorder slot immediately —
6077
+ // the row grows by one row height on the dropped side, so the
6078
+ // insertion point is a real gap the block slides into (the user
6079
+ // report: a thin line is not "撑开让位").
6080
+ className: cls('bw-row', 'bw-container-row',
6081
+ dropSlot === 'before' && 'bw-slot-above',
6082
+ dropSlot === 'after' && 'bw-slot-below',
6083
+ dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6032
6084
  'data-row-key': dataRowKey,
6033
6085
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
6034
6086
  onClick: onToggle,
@@ -6048,13 +6100,17 @@ window.__ModuleLoader__.load({
6048
6100
  )
6049
6101
  }
6050
6102
 
6051
- function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onMenu, onContextMenu, currentInside, menuOpen, dropHalf, dropInto, dragEvents, custStyle, iconMode, pulse, newSessionLabel, newSessionAria, menuAria, hoverBody, dataRowKey, t }) {
6103
+ function WorkspaceRow({ workspace, depth, count, sessionsOpen, onToggle, onStart, onContextMenu, currentInside, menuOpen, dropHalf, dropInto, dragEvents, custStyle, iconMode, pulse, newSessionLabel, newSessionAria, hoverBody, dataRowKey, t }) {
6052
6104
  const iconEl = iconOf(iconMode, sessionsOpen)
6053
6105
  const iconChild = pulse
6054
6106
  ? E(PulseGlow, { state: pulse }, iconEl || (typeof ui.StateDot === 'function' ? E(ui.StateDot, { state: pulse, size: 10 }) : null))
6055
6107
  : iconEl
6056
6108
  let row = E('div', {
6057
- className: cls('bw-row', 'bw-container-row', currentInside && 'bw-row-current', menuOpen && 'menu-open', dropHalf === 'before' && 'bw-drop-before', dropHalf === 'after' && 'bw-drop-after', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6109
+ // v0.36.4: workspace rows open the same empty reorder slot a group
6110
+ // row does (the official 12px insert line alone read as "没撑开").
6111
+ // The half split is judged in LOGICAL height, so the grown row
6112
+ // cannot flip its own band under the pointer.
6113
+ className: cls('bw-row', 'bw-container-row', currentInside && 'bw-row-current', menuOpen && 'menu-open', dropHalf === 'before' && 'bw-slot-above', dropHalf === 'after' && 'bw-slot-below', dropInto && 'bw-drop-into', dropInto && 'bw-drop-into-strong'),
6058
6114
  'data-row-key': dataRowKey,
6059
6115
  style: { '--bw-indent': (depth * 12) + 'px', ...(custStyle || {}), ...(pulse ? { '--bw-pulse-color': PULSE_COLORS[pulse] || PULSE_COLORS.ongoing } : null) },
6060
6116
  onClick: onToggle,
@@ -6071,14 +6127,10 @@ window.__ModuleLoader__.load({
6071
6127
  E('span', { className: cls('bw-row-icon', 'bw-folder-cell', currentInside && !pulse && 'bw-folder-active') }, iconChild),
6072
6128
  E('span', { className: cls('bw-row-label', pulse && custStyle && custStyle.textShadow && 'bw-pulse-text'), title: workspace.title || workspace.leaf }, workspace.leaf),
6073
6129
  count > 0 ? E('span', { className: 'bw-row-count' }, String(count)) : null,
6130
+ // The in-row ellipsis is retired (mirrors the session-row decision):
6131
+ // the ctx surface is the right-click path; hover keeps only the
6132
+ // new-session affordance.
6074
6133
  E('span', { className: 'bw-row-actions', onClick: (e) => e.stopPropagation() },
6075
- E('button', {
6076
- type: 'button',
6077
- className: 'bw-row-btn',
6078
- 'aria-label': menuAria || '...',
6079
- title: menuAria || undefined,
6080
- onClick: (e) => { e.stopPropagation(); if (onMenu) onMenu(e) },
6081
- }, icon('IconEllipsisOutline16', 15)),
6082
6134
  E('button', {
6083
6135
  type: 'button',
6084
6136
  className: 'bw-row-btn',
@@ -6487,7 +6539,7 @@ window.__ModuleLoader__.load({
6487
6539
  'IconSkillOutline16', 'IconSparkle16', 'IconNewChatOutline16',
6488
6540
  'IconCheckOutline14', 'IconCheckOutline16', 'IconChevronDownOutline14', 'IconChevronLeftOutline14',
6489
6541
  'IconChevronRightOutline14', 'IconChevronUpOutline14', 'IconCloseOutline16', 'IconDarkOutline16',
6490
- 'IconEditOutline16', 'IconEllipsisOutline16', 'IconFolderClose16', 'IconFolderOpen16',
6542
+ 'IconEditOutline16', 'IconFolderClose16', 'IconFolderOpen16',
6491
6543
  'IconFolderOpenOutline16', 'IconFullscreenOutline16', 'IconLightOutline16', 'IconLinkOutline14',
6492
6544
  'IconLoadingOutline16', 'IconPanelLeftOutline16', 'IconPaperclipOutline16', 'IconPersonalizationOutline16',
6493
6545
  'IconPlayOutline16', 'IconPauseOutline16', 'IconPlusOutline16', 'IconQuestionOutline14',
@@ -6946,7 +6998,6 @@ window.__ModuleLoader__.load({
6946
6998
  const localStyling = useStore ? (useStore(s => s.styling) || {}) : {}
6947
6999
  const compactChains = prefs.compactChains !== false
6948
7000
  const statusPulse = prefs.statusPulse !== false
6949
- const sessionMenu = prefs.sessionMenu !== false
6950
7001
  const rowActions = prefs.rowActions !== false
6951
7002
  // v0.30.2: auto titles keep the '/' group prefix — on unless opted out.
6952
7003
  const graftPrefix = prefs.graftTitlePrefix !== false
@@ -7093,18 +7144,6 @@ window.__ModuleLoader__.load({
7093
7144
  E('div', { className: 'bw-hint' }, t('settings.sessionLimit.hint')),
7094
7145
  ),
7095
7146
  fold('settings.group.interaction',
7096
- E('div', { className: 'bw-setting-row' },
7097
- E('div', { className: 'bw-setting-label' }, t('settings.sessionMenu')),
7098
- E('button', {
7099
- type: 'button',
7100
- role: 'switch',
7101
- 'aria-checked': sessionMenu,
7102
- 'aria-label': t('settings.sessionMenu'),
7103
- className: cls('bw-switch', sessionMenu && 'bw-switch-on'),
7104
- onClick: () => { setPref('sessionMenu', !sessionMenu) },
7105
- }, E('span', { className: 'bw-switch-thumb' })),
7106
- ),
7107
- E('div', { className: 'bw-hint' }, t('settings.sessionMenu.hint')),
7108
7147
  E('div', { className: 'bw-setting-row' },
7109
7148
  E('div', { className: 'bw-setting-label' }, t('settings.graft.title')),
7110
7149
  E('button', {
@@ -7346,6 +7385,12 @@ window.__ModuleLoader__.load({
7346
7385
  const wsFolders = typeof useStore === 'function'
7347
7386
  ? useStore(state => (state && state.folders && state.folders.ws) || EMPTY_FOLDERS)
7348
7387
  : EMPTY_FOLDERS
7388
+ // Same persisted group order the sidebar uses (v0.36.4) — a chooser that
7389
+ // listed groups alphabetically while the tree showed the dragged order
7390
+ // would contradict the surface it selects for.
7391
+ const pickerFolderOrder = typeof useStore === 'function'
7392
+ ? useStore(state => (state && state.folderOrder) || EMPTY_FOLDERS)
7393
+ : EMPTY_FOLDERS
7349
7394
  // Occupancy is the only reactive fact here: the ledger read re-runs on
7350
7395
  // every render, so the count just forces the re-render when the shipped
7351
7396
  // occupant loads or unloads (its identity is stable while occupied).
@@ -7367,10 +7412,10 @@ window.__ModuleLoader__.load({
7367
7412
  // Same construction the sidebar runs (buildTree + optional chain
7368
7413
  // compression), so both surfaces answer the same view options.
7369
7414
  const tree = React.useMemo(() => {
7370
- const built = buildTree(items, groupMode, wsFolders)
7415
+ const built = buildTree(items, groupMode, wsFolders, pickerFolderOrder)
7371
7416
  if (!compactChains) return built
7372
7417
  return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
7373
- }, [items, groupMode, wsFolders, compactChains])
7418
+ }, [items, groupMode, wsFolders, pickerFolderOrder, compactChains])
7374
7419
 
7375
7420
  const rows = React.useMemo(() => {
7376
7421
  const out = []
@@ -7601,6 +7646,10 @@ window.__ModuleLoader__.load({
7601
7646
  const foldersMap = useStore ? (useStore(s => s.folders) || {}) : {}
7602
7647
  const wsFolders = foldersMap && typeof foldersMap.ws === 'object' && foldersMap.ws !== null ? foldersMap.ws : EMPTY_FOLDERS
7603
7648
  const sessFolders = foldersMap && typeof foldersMap.sess === 'object' && foldersMap.sess !== null ? foldersMap.sess : EMPTY_FOLDERS
7649
+ // Persisted group order (v0.36.4): { [parentKey]: name[] }. Absent on
7650
+ // every snapshot written before it existed — the seed name sort then
7651
+ // stands, so old states render unchanged.
7652
+ const folderOrderMap = useStore ? (useStore(s => s.folderOrder) || EMPTY_FOLDERS) : EMPTY_FOLDERS
7604
7653
  // View options (v0.13). Selector reads take fallbacks: hydration
7605
7654
  // replaces the whole persisted value, so states written by older
7606
7655
  // plugin versions lack these keys (hard store discipline).
@@ -7629,10 +7678,9 @@ window.__ModuleLoader__.load({
7629
7678
  const shared = makeSharedWrites(actions, stylingMap)
7630
7679
  const compactChains = prefsMap.compactChains !== false
7631
7680
  const statusPulse = prefsMap.statusPulse !== false
7632
- // Session-action surface gates (v0.20.0): the right-click menu and the
7633
- // official-style hover strip can each be turned off; defaults follow
7634
- // the plugin's historical behavior (right-click on, quick buttons on).
7635
- const sessionMenuEnabled = prefsMap.sessionMenu !== false
7681
+ // Session-action surface gate (v0.20.0): the hover quick buttons. The
7682
+ // right-click ctx menu is always on since the in-row "..." triggers
7683
+ // retired (session row v0.35.0, workspace row v0.35.1).
7636
7684
  const rowActionsEnabled = prefsMap.rowActions !== false
7637
7685
  // Auto color + icon for new items (v0.24.0). Default OFF: absent key =
7638
7686
  // off, so every existing snapshot keeps its behavior untouched.
@@ -7946,13 +7994,22 @@ window.__ModuleLoader__.load({
7946
7994
  const [copyNotice, setCopyNotice] = React.useState(null) // { seq, ok } copy-session-id notice
7947
7995
  const [customize, setCustomize] = React.useState(null) // { kind, entryKey, name }
7948
7996
  const [errorText, setErrorText] = React.useState(null)
7949
- const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session', source, over } | null
7997
+ const [drag, setDrag] = React.useState(null) // { kind: 'workspace'|'session'|'folder', source, over } | null
7950
7998
  // Workspace drags arm their state one frame LATE (see workspaceDragEvents):
7951
7999
  // arming synchronously re-renders during the dragstart dispatch, the chain
7952
8000
  // expansion inserts rows above the drag source, the cursor leaves the
7953
8001
  // source element, and Chromium cancels the whole gesture. dragEnd clears
7954
8002
  // the timer so a same-tick cancel never leaves a ghost drag behind.
7955
8003
  const wsDragArmTimer = React.useRef(null)
8004
+ // (v0.36.3) The 600ms group-hover auto-expand is retired: it kept
8005
+ // opening groups under a passing cursor ("变下级会被撑开"). The
8006
+ // deterministic reveal now lives in commitFolderMoveInto — the target
8007
+ // group opens when the members actually land.
8008
+ // (v0.36.4) The hover-intent arming is retired. It made the reorder
8009
+ // slot appear only after a ~200ms rest on the band, so a normal drag
8010
+ // across a group row showed the thin insert line and never the empty
8011
+ // slot — the user report "还是一条蓝线,没拨开让我插入". The edge bands
8012
+ // are 30% wide and geometrically judged, so they can answer at once.
7956
8013
  // Quote-on-land eligibility sets (mount-scoped):
7957
8014
  // - blankSeen: ids observed BLANK in any snapshot. A blank row is a
7958
8015
  // freshly created New Session, so ONLY these may ever receive an
@@ -8109,7 +8166,12 @@ window.__ModuleLoader__.load({
8109
8166
  const normalizedQuery = query.trim().toLowerCase()
8110
8167
  const now = Date.now()
8111
8168
 
8112
- const fail = (text) => { setFlowOpen(false); setDialog(null); setErrorText(String(text || 'unknown error')) }
8169
+ // v0.36.4: no setFlowOpen here. flowOpen belongs to the PICKER component
8170
+ // (WorkspacePicker), not to this browser: referencing it from here threw
8171
+ // 'ReferenceError: setFlowOpen is not defined' on every failure path —
8172
+ // including the .catch(fail) of a group move-into, which then silently
8173
+ // aborted the rename ('变下级没反应').
8174
+ const fail = (text) => { setDialog(null); setErrorText(String(text || 'unknown error')) }
8113
8175
 
8114
8176
  const styleEntry = (key) => stylingMap[key] || null
8115
8177
  // Effective appearance = the settings card's default, overridden field by
@@ -8138,7 +8200,11 @@ window.__ModuleLoader__.load({
8138
8200
  return Object.keys(style).length > 0 ? style : null
8139
8201
  }
8140
8202
  const keyOf = (kind, payload) => {
8141
- if (kind === 'folder') return 'folder:' + payload.path
8203
+ // Render keys folders by idPath (renderPlainFolder), which prefixes
8204
+ // the ws id to disambiguate same-named groups across disk levels;
8205
+ // saving by payload.path alone made sub-level group styling
8206
+ // unreachable (name-slash group appearance never applied).
8207
+ if (kind === 'folder') return 'folder:' + (payload.idPath || payload.path)
8142
8208
  if (kind === 'workspace') return 'workspace:' + payload.workspaceId
8143
8209
  if (kind === 'session') return 'session:' + payload.id
8144
8210
  if (kind === 'sgroup') return 'sgroup:' + payload.workspaceId + '|' + payload.path
@@ -8172,26 +8238,33 @@ window.__ModuleLoader__.load({
8172
8238
  pinnedPartitionInPlace(ungrouped)
8173
8239
  }
8174
8240
 
8175
- // While a workspace drag is active, single-child chains render UNCOMPRESSED:
8176
- // a merged "group/workspace" row hides every folder level of the chain inside
8177
- // its label, and those levels are exactly the drop targets for "move into
8178
- // this group". Suspending the merge for the drag's duration re-exposes each
8179
- // level as a real folder row (folder expansion defaults apply); when the
8180
- // drag ends, the chains merge back. Session drags keep the merged view —
8181
- // their drop targets live inside workspace rows, which compression merges.
8182
- const draggingWorkspace = drag !== null && drag.kind === 'workspace'
8241
+ // (v0.36.4) The drag-time chain EXPANSION is retired. Suspending the
8242
+ // merge while a workspace was dragged re-exposed every single-child
8243
+ // chain as folder rows, so the tree visibly re-laid itself one frame
8244
+ // after dragstart and every drop target below the source shifted —
8245
+ // the user report "拖拽中树会乱/落点漂移". Drop targets are now
8246
+ // answered by the stable render; nothing re-flows mid-drag.
8183
8247
  const tree = React.useMemo(() => {
8184
- const built = buildTree(items, workspaceGroupMode, wsFolders)
8185
- if (!compactChains || draggingWorkspace) return built
8186
- return { ...built, folders: built.folders.map((f) => materializeChain(compressTree(f))), workspaces: built.workspaces }
8187
- }, [items, compactChains, draggingWorkspace, workspaceGroupMode, wsFolders])
8248
+ const built = buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap)
8249
+ if (!compactChains) return built
8250
+ const folders = built.folders.map((f) => materializeChain(compressTree(f)))
8251
+ // v0.36.5: keep the MIXED root sequence across chain compression —
8252
+ // the compressed node stands in for the raw folder it came from, so
8253
+ // a bare workspace stays interleaved where the user put it.
8254
+ const byName = new Map()
8255
+ for (let i = 0; i < built.folders.length; i += 1) byName.set(built.folders[i].name, folders[i])
8256
+ const seq = Array.isArray(built.seq)
8257
+ ? built.seq.map((item) => item.kind === 'folder' ? { kind: 'folder', node: byName.get(item.node.name) || item.node } : item)
8258
+ : built.seq
8259
+ return { ...built, folders, workspaces: built.workspaces, seq }
8260
+ }, [items, compactChains, workspaceGroupMode, wsFolders, folderOrderMap])
8188
8261
  // UNCOMPRESSED tree, for the new-group dialog only. compressTree merges a
8189
8262
  // single-child chain into one row that stands for BOTH a group and the
8190
8263
  // workspace under it — fine for drawing, useless for choosing a
8191
8264
  // container: one half must stay unselectable and the other must be
8192
8265
  // selectable, and a merged row cannot be both. (It also carried no
8193
8266
  // `name`, which is how a lone "E" rendered blank.)
8194
- const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders), [items, workspaceGroupMode, wsFolders])
8267
+ const rawTree = React.useMemo(() => buildTree(items, workspaceGroupMode, wsFolders, folderOrderMap), [items, workspaceGroupMode, wsFolders, folderOrderMap])
8195
8268
  // Disk-layer ancestry for drag semantics (same derivation as buildTree).
8196
8269
  const diskParentMap = React.useMemo(() => diskParentMapOf(items), [items])
8197
8270
 
@@ -8391,9 +8464,8 @@ window.__ModuleLoader__.load({
8391
8464
  const unarchiveAvailable = typeof unarchiveSession === 'function'
8392
8465
  const sessionActions = {
8393
8466
  ot: officialT,
8394
- // Right-click is the default trigger; turning the setting off moves
8395
- // the trigger to the official-style "..." hover button.
8396
- trigger: !sessionMenuEnabled,
8467
+ // Right-click is the only ctx trigger: the in-row "..." affordances
8468
+ // are retired (session row in v0.35.0, workspace row likewise).
8397
8469
  quick: rowActionsEnabled,
8398
8470
  pin: pinAvailable
8399
8471
  ? (id, pinning) => {
@@ -8695,11 +8767,72 @@ window.__ModuleLoader__.load({
8695
8767
  }
8696
8768
  }, [drag === null])
8697
8769
 
8770
+ // Geometry helpers shared by both reorder targets. v0.36.4 judges in
8771
+ // v0.36.4 geometry: an open reorder slot (bw-slot-above / -below) pads
8772
+ // the row open by one logical row (padding, so the box grows to two
8773
+ // rows). Judge the pointer against the row's ORIGINAL position, not the
8774
+ // padded box: the original position is the half its content did NOT
8775
+ // slide into — the pad above for a before-slot, the remaining content
8776
+ // box for an after-slot. Judging there keeps a still pointer from
8777
+ // flipping the slot the moment it opens (the reported "抽搐"), while
8778
+ // still letting the pointer cross the original midpoint to switch
8779
+ // direction — the padded half answers null instead of a stale band.
8780
+ const originalBoxOf = (el) => {
8781
+ const rect = el.getBoundingClientRect()
8782
+ const style = getComputedStyle(el)
8783
+ const padTop = parseFloat(style.paddingTop) || 0
8784
+ const padBottom = parseFloat(style.paddingBottom) || 0
8785
+ if (padTop > 0) return { top: rect.top, bottom: rect.top + padTop }
8786
+ if (padBottom > 0) return { top: rect.top, bottom: rect.bottom - padBottom }
8787
+ return { top: rect.top, bottom: rect.bottom }
8788
+ }
8789
+ // null = the pointer sits in the half the open slot vacated: the caller
8790
+ // keeps its current drop target instead of re-deciding on geometry that
8791
+ // only exists because of that slot.
8792
+ // v0.36.5 MIXED LEVEL SEQUENCE. buildTree attaches a `seq` to every
8793
+ // level (group rows and BARE workspace rows interleaved by the
8794
+ // persisted folderOrder, whose tokens may be a group name or
8795
+ // "w:<workspaceId>"); chain-compressed nodes carry no seq and keep the
8796
+ // historical folders-then-workspaces order. Both the renderer and the
8797
+ // reorder commit read the SAME sequence, so what the user sees is what
8798
+ // the drop edits.
8799
+ const levelSeqOf = (level) => (level && Array.isArray(level.seq))
8800
+ ? level.seq
8801
+ : [
8802
+ ...(((level && level.folders) || []).map((node) => ({ kind: 'folder', node }))),
8803
+ ...(((level && level.workspaces) || []).map((entry) => ({ kind: 'ws', entry }))),
8804
+ ]
8805
+ const levelTokenOf = (item) => item.kind === 'ws' ? 'w:' + item.entry.workspaceId : item.node.name
8698
8806
  const rowHalf = (event) => {
8699
- const rect = event.currentTarget.getBoundingClientRect()
8700
- return event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
8807
+ const box = originalBoxOf(event.currentTarget)
8808
+ const y = event.clientY
8809
+ if (y < box.top || y > box.bottom) return null
8810
+ return y < (box.top + box.bottom) / 2 ? 'before' : 'after'
8811
+ }
8812
+ // Three-band targeting for group rows (react-arborist style): the
8813
+ // outer 30% of the CONTENT means "reorder against this group", the
8814
+ // middle 40% means "drop into" — so move-into (the framed highlight)
8815
+ // is easy to hit and reorder never swallows the pointer. v0.36.3
8816
+ // widened the bands from a quarter: at 25% they were 8.5px on a 34px
8817
+ // row and practically unhittable (every drop landed "into" instead
8818
+ // of reordering beside).
8819
+ const rowZone = (event) => {
8820
+ const box = originalBoxOf(event.currentTarget)
8821
+ const y = event.clientY
8822
+ if (y < box.top || y > box.bottom) return null
8823
+ const h = box.bottom - box.top
8824
+ if (h < 2) return 'into'
8825
+ const rel = (y - box.top) / h
8826
+ if (rel < 0.3) return 'before'
8827
+ if (rel > 0.7) return 'after'
8828
+ return 'into'
8701
8829
  }
8702
8830
  const dragMatches = (kind) => drag !== null && drag.kind === kind
8831
+ // A refused hover must DROP the highlight it already owns: leaving a
8832
+ // stale {over} in place drew the previous target's INTO frame on a row
8833
+ // that no longer accepts anything (the user reads it as "it will land
8834
+ // here" and the drop then goes elsewhere).
8835
+ const clearOver = (targetKind, target) => setDrag((current) => (current && current.over && current.over.kind === targetKind && current.over.target === target) ? { ...current, over: null } : current)
8703
8836
  const canDragWorkspace = typeof insertWorkspaceBefore === 'function'
8704
8837
  // Session REORDER needs the host insertSessionBefore action; dragging a
8705
8838
  // session onto a sub-group row (a pure rename) stays available without it.
@@ -8711,12 +8844,26 @@ window.__ModuleLoader__.load({
8711
8844
 
8712
8845
  /* --------------------- workspace drag & drop -------------------- */
8713
8846
 
8847
+ // v0.36.5: a workspace row answers the SAME slot for both drag kinds —
8848
+ // a workspace slides between workspace rows, a whole group block slides
8849
+ // beside one (mixed reorder), so the visual is one shared mechanism.
8714
8850
  const wsDropHalf = (workspaceId) => {
8715
- if (!dragMatches('workspace')) return null
8851
+ if (!dragMatches('workspace') && !dragMatches('folder')) return null
8716
8852
  const over = drag.over
8717
8853
  return over && over.kind === 'workspace' && over.target === workspaceId ? over.half : null
8718
8854
  }
8719
- const wsDropInto = (path) => dragMatches('workspace') && drag.over && drag.over.kind === 'folder' && drag.over.target === path
8855
+ // Folder rows light up for a workspace drop (regroup one workspace)
8856
+ // AND a folder drop (whole-group move) alike — the INTO band only:
8857
+ // an edge-band reorder slot draws the official insert line instead.
8858
+ const wsDropInto = (path) => (dragMatches('workspace') || dragMatches('folder')) && drag.over && drag.over.kind === 'folder' && drag.over.target === path && (!drag.over.zone || drag.over.zone === 'into')
8859
+ // v0.36.4: an edge band on a group row answers IMMEDIATELY with the
8860
+ // empty reorder slot (bw-slot-above/below) — the drop position is
8861
+ // visible the moment the pointer enters the band, no intent wait.
8862
+ // v0.36.5: BOTH sources open the slot on a group row. A folder source
8863
+ // reorders the group block; a WORKSPACE source interleaves one bare
8864
+ // workspace row beside the group — the gesture "insert as the first
8865
+ // row" that used to be impossible because every group rendered first.
8866
+ const wsFolderSlotOf = (folderIdPath) => ((dragMatches('folder') || dragMatches('workspace')) && drag.over && drag.over.kind === 'folder' && drag.over.target === folderIdPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) ? drag.over.zone : null
8720
8867
  const workspaceDragEvents = (workspace) => ({
8721
8868
  draggable: !searching && canDragWorkspace,
8722
8869
  onDragStart: (event) => {
@@ -8745,7 +8892,7 @@ window.__ModuleLoader__.load({
8745
8892
  if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
8746
8893
  wsDragArmTimer.current = setTimeout(() => {
8747
8894
  wsDragArmTimer.current = null
8748
- setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId) }, over: null })
8895
+ setDrag({ kind: 'workspace', source: { workspaceId: workspace.workspaceId, leaf: sourceLeaf, folderPath: sourceFolder, disk: diskParentMap.get(workspace.workspaceId), levelKey: workspace.levelKey || '' }, over: null })
8749
8896
  }, 0)
8750
8897
  },
8751
8898
  onDragEnd: () => {
@@ -8769,11 +8916,57 @@ window.__ModuleLoader__.load({
8769
8916
  : (current ? { ...current, over: { kind: 'ws-root', target: workspace.workspaceId } } : current))
8770
8917
  return
8771
8918
  }
8919
+ // v0.36.5 MIXED REORDER: a GROUP block can slide beside a bare
8920
+ // workspace row of the same level (the mirror gesture of a
8921
+ // workspace sliding beside a group row). Siblings only — a group
8922
+ // that lives in another container has no position in this level.
8923
+ if (dragMatches('folder')) {
8924
+ const sourceLevel = drag.source.levelKey || ''
8925
+ if (sourceLevel !== (workspace.levelKey || '')) {
8926
+ // v0.36.7 INTO on the OWNING disk row: a group block dropped on
8927
+ // the workspace row that CONTAINS it answers the same framed
8928
+ // INTO a workspace row gets on a group row ("放入这个工作区
8929
+ // 下"). The only position that means without rewriting a path
8930
+ // is the tail of the level it already lives in, so the drop
8931
+ // commits moveFolderToLevelEnd. Every other cross-level target
8932
+ // stays refused: the disk layer is a filesystem fact.
8933
+ if (sourceLevel === (workspace.subPrefix || '')) {
8934
+ event.preventDefault()
8935
+ event.stopPropagation()
8936
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
8937
+ setDrag(current => (current && current.over && current.over.kind === 'ws-level' && current.over.target === workspace.workspaceId)
8938
+ ? current
8939
+ : (current ? { ...current, over: { kind: 'ws-level', target: workspace.workspaceId } } : current))
8940
+ } else {
8941
+ clearOver('workspace', workspace.workspaceId)
8942
+ clearOver('ws-level', workspace.workspaceId)
8943
+ }
8944
+ return
8945
+ }
8946
+ event.preventDefault()
8947
+ event.stopPropagation()
8948
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
8949
+ const half = rowHalf(event)
8950
+ if (half === null) return
8951
+ setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8952
+ ? current
8953
+ : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
8954
+ return
8955
+ }
8772
8956
  if (!dragMatches('workspace')) return
8957
+ // v0.36.4: the SOURCE row is never its own drop target. A reorder
8958
+ // slot pads a row open by one logical row, so the row the drag
8959
+ // started on ends up covering the pointer's first stop — without
8960
+ // this gate the source row swallowed the target, the drop landed on
8961
+ // itself, commitWorkspaceDrop no-oped and the gesture read as
8962
+ // "拖了没反应".
8963
+ if (drag.source && drag.source.workspaceId === workspace.workspaceId) return
8773
8964
  event.preventDefault()
8774
8965
  event.stopPropagation()
8775
8966
  try { event.dataTransfer.dropEffect = 'move' } catch { }
8776
8967
  const half = rowHalf(event)
8968
+ // Pointer in the half the open slot vacated: keep this row's slot.
8969
+ if (half === null) return
8777
8970
  setDrag(current => (current && current.over && current.over.kind === 'workspace' && current.over.target === workspace.workspaceId && current.over.half === half)
8778
8971
  ? current
8779
8972
  : (current ? { ...current, over: { kind: 'workspace', target: workspace.workspaceId, half } } : current))
@@ -8787,6 +8980,24 @@ window.__ModuleLoader__.load({
8787
8980
  commitSessionMoveOut(workspace.workspaceId)
8788
8981
  return
8789
8982
  }
8983
+ if (dragMatches('folder')) {
8984
+ const sourceLevel = drag.source.levelKey || ''
8985
+ if (sourceLevel !== (workspace.levelKey || '')) {
8986
+ // v0.36.7: the group's OWNING disk row accepts the framed INTO
8987
+ // (see onDragOver) — put the block back at that level's tail.
8988
+ if (sourceLevel === (workspace.subPrefix || '')) {
8989
+ event.preventDefault()
8990
+ event.stopPropagation()
8991
+ commitFolderMoveToLevelEnd(drag.source)
8992
+ }
8993
+ return
8994
+ }
8995
+ event.preventDefault()
8996
+ event.stopPropagation()
8997
+ const half = drag.over && drag.over.kind === 'workspace' && drag.over.target === workspace.workspaceId ? drag.over.half : rowHalf(event)
8998
+ commitLevelReorder({ kind: 'folder', source: drag.source }, { kind: 'workspace', workspace }, half || 'before')
8999
+ return
9000
+ }
8790
9001
  if (!dragMatches('workspace')) return
8791
9002
  event.preventDefault()
8792
9003
  event.stopPropagation()
@@ -8794,21 +9005,214 @@ window.__ModuleLoader__.load({
8794
9005
  commitWorkspaceDrop(workspace, half)
8795
9006
  },
8796
9007
  })
8797
- const folderDropEvents = (path) => ({
9008
+ // A folder row accepts BOTH drag kinds: a workspace drop regroups that
9009
+ // one workspace (commitWorkspaceMoveInto), a folder drop moves the
9010
+ // whole group — every member retitled under the target prefix
9011
+ // (commitFolderMoveInto, the drag twin of submitFolderRename).
9012
+ // Dropping a group onto itself or its own descendant would loop, so
9013
+ // the hover refuses there and the browser shows its "no drop" cursor.
9014
+ const namePathOf = (folderIdPath) => {
9015
+ const cutAt = folderIdPath.lastIndexOf('//')
9016
+ return cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
9017
+ }
9018
+ // idPath = [<wsId>//]*<namePath>: the owner prefix names the disk
9019
+ // level a group renders in. Reordering only makes sense between
9020
+ // groups of the SAME level — across levels a group's position
9021
+ // belongs to its own container.
9022
+ const ownerPrefixOf = (folderIdPath) => {
9023
+ const cutAt = folderIdPath.lastIndexOf('//')
9024
+ return cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
9025
+ }
9026
+ // PARENT container identity of a group row (v0.36.4) — the key its
9027
+ // sibling order is persisted under. The ROOT level of a disk scope
9028
+ // answers the scope prefix ('' at the top level, '<wsId>' one disk
9029
+ // level down); a deeper group answers the idPath of its parent group.
9030
+ const parentKeyOf = (folderIdPath) => {
9031
+ const cutAt = folderIdPath.lastIndexOf('//')
9032
+ const prefix = cutAt === -1 ? '' : folderIdPath.slice(0, cutAt)
9033
+ const namePath = cutAt === -1 ? folderIdPath : folderIdPath.slice(cutAt + 2)
9034
+ const segs = namePath.split('/')
9035
+ segs.pop()
9036
+ const parentNamePath = segs.join('/')
9037
+ return parentNamePath === '' ? prefix : prefix + '//' + parentNamePath
9038
+ }
9039
+ // Locate the container a group order list belongs to inside the RAW
9040
+ // (uncompressed) tree: the ROOT level, a disk sub-level (a
9041
+ // workspace's `sub`), or a group node whose `folders` are its
9042
+ // children. Compression never runs on rawTree, so every group the
9043
+ // user can drop beside still has its own idPath here.
9044
+ const folderContainerOf = (key) => {
9045
+ if (key === '') return rawTree
9046
+ let hit = null
9047
+ const walk = (level) => {
9048
+ if (hit || !level) return
9049
+ for (const w of level.workspaces || []) {
9050
+ if (hit) return
9051
+ if (w.subPrefix === key && w.sub) { hit = w.sub; return }
9052
+ if (w.sub) walk(w.sub)
9053
+ }
9054
+ for (const f of level.folders || []) {
9055
+ if (hit) return
9056
+ if (f.idPath === key) { hit = f; return }
9057
+ walk(f)
9058
+ }
9059
+ }
9060
+ walk(rawTree)
9061
+ return hit
9062
+ }
9063
+ // Whole-block member ids of a name group, from this rendering level
9064
+ // down: a reorder slides EVERY nested workspace (prefixes untouched,
9065
+ // so sub-groups travel with the block; only the registry order
9066
+ // shifts).
9067
+ const collectMemberIds = (folderNode) => {
9068
+ const acc = []
9069
+ const walk = (level) => {
9070
+ for (const w of level.workspaces || []) acc.push(w.workspaceId)
9071
+ for (const f of level.folders || []) {
9072
+ if (f.kind === 'ws') acc.push(f.workspace.workspaceId)
9073
+ else walk(f)
9074
+ }
9075
+ }
9076
+ walk(folderNode)
9077
+ return acc
9078
+ }
9079
+ const folderDropEvents = (idPath, path, node) => ({
9080
+ draggable: !searching,
9081
+ onDragStart: (event) => {
9082
+ if (searching) return
9083
+ event.stopPropagation()
9084
+ try {
9085
+ event.dataTransfer.effectAllowed = 'move'
9086
+ event.dataTransfer.setData('text/plain', idPath)
9087
+ } catch { /* drag payload is best-effort */ }
9088
+ // Same one-frame arm as the workspace drag: Chromium cancels a
9089
+ // just-started drag whose source element moves under the cursor.
9090
+ if (wsDragArmTimer.current !== null) clearTimeout(wsDragArmTimer.current)
9091
+ wsDragArmTimer.current = setTimeout(() => {
9092
+ wsDragArmTimer.current = null
9093
+ setDrag({ kind: 'folder', source: { idPath, path, memberIds: collectMemberIds(node), levelKey: parentKeyOf(idPath) }, over: null })
9094
+ }, 0)
9095
+ },
9096
+ onDragEnd: () => {
9097
+ if (wsDragArmTimer.current !== null) { clearTimeout(wsDragArmTimer.current); wsDragArmTimer.current = null }
9098
+ setDrag(null)
9099
+ },
8798
9100
  onDragOver: (event) => {
8799
- if (!dragMatches('workspace')) return
9101
+ // Three bands on a group row: the outer 30% bands reorder this
9102
+ // group among its SIBLINGS (an empty slot opens at once — the
9103
+ // drop position is visible the moment the band is entered); the
9104
+ // middle 40% moves the source INSIDE the hovered group (framed,
9105
+ // never opened). Workspace sources keep whole-row move-into
9106
+ // semantics.
9107
+ //
9108
+ // SIBLINGS means the SAME PARENT (parentKeyOf): a band reorder
9109
+ // only permutes one level's group name list, it never rewrites a
9110
+ // title prefix. Cross-parent and self/descendant targets are
9111
+ // refused — no preventDefault, so the platform shows its own
9112
+ // 'no drop' cursor, which is the truth.
9113
+ //
9114
+ // v0.36.4: once THIS row already holds a reorder slot, the whole
9115
+ // original row position answers before/after ONLY (a plain
9116
+ // midpoint split). Judging the three bands again there reintroduced
9117
+ // the INTO band on the very row that just padded open: a pointer
9118
+ // drifting a few pixels inside it flipped to INTO, closed the slot
9119
+ // and read as a flicker ("抽搐") — and worse, the drop then landed
9120
+ // as move-into (a no-op) instead of the reorder the user aimed at.
9121
+ // The three bands still decide the FIRST entry, so INTO (move the
9122
+ // source inside this group) stays reachable.
9123
+ const slotOpen = drag.over !== null && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')
9124
+ const zone = slotOpen ? rowHalf(event) : rowZone(event)
9125
+ const isFolder = dragMatches('folder')
9126
+ const isWorkspace = dragMatches('workspace')
9127
+ if (isFolder) {
9128
+ const targetPath = namePathOf(idPath)
9129
+ // Self / descendant: looping.
9130
+ if (drag.source.path === targetPath || targetPath.startsWith(drag.source.path + '/')) { clearOver('folder', idPath); return }
9131
+ // Ancestor: the group already lives inside. The DIRECT parent is
9132
+ // the container it lives in, so v0.36.7 reopens its MIDDLE band
9133
+ // (the framed INTO): "放进那个组" can only mean "move to the end
9134
+ // of that group", the same feedback a workspace row gets when
9135
+ // dropped INTO its own group. Edge bands still mean nothing
9136
+ // there (leaving a parent is a title rename, not an order
9137
+ // change), and a further ancestor stays refused.
9138
+ if (targetPath !== '' && drag.source.path.startsWith(targetPath + '/')) {
9139
+ const cutAt = drag.source.path.lastIndexOf('/')
9140
+ const directParent = cutAt === -1 ? '' : drag.source.path.slice(0, cutAt)
9141
+ if (targetPath !== directParent || zone !== 'into') { clearOver('folder', idPath); return }
9142
+ }
9143
+ // Pointer in the half the open slot vacated: keep this row's slot
9144
+ // (re-deciding there would flicker the slot it just opened).
9145
+ if (zone === null) {
9146
+ if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
9147
+ event.preventDefault()
9148
+ event.stopPropagation()
9149
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
9150
+ }
9151
+ return
9152
+ }
9153
+ // Edge bands permute ONE level's sibling list; a cross-parent
9154
+ // band is refused. Move-into (the middle band) stays open across
9155
+ // parents: it is a batch rename, which does reach another group.
9156
+ if (zone !== 'into' && parentKeyOf(idPath) !== parentKeyOf(drag.source.idPath)) { clearOver('folder', idPath); return }
9157
+ } else if (!isWorkspace) {
9158
+ return
9159
+ } else {
9160
+ // v0.36.6 LIFT: a bare workspace row slides beside a group of ANY
9161
+ // level it can reach. Same level keeps the v0.36.5 mixed reorder.
9162
+ // Another level is a LIFT: the workspace leaves its current group
9163
+ // and joins the target group's level, inserted right there. That
9164
+ // is the "insert as the FIRST row" gesture the user kept
9165
+ // reporting: the hover used to be refused silently, so {over}
9166
+ // kept the previous frame's INTO, and the drop became a
9167
+ // move-into that left the row last inside the group. A lift may
9168
+ // not cross a DISK boundary (a workspace's folder is a
9169
+ // filesystem fact), so the target level must live in the same
9170
+ // disk scope as the source. The middle band keeps its old
9171
+ // meaning: move the workspace INTO this group.
9172
+ if (zone === null) {
9173
+ if (drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after')) {
9174
+ event.preventDefault()
9175
+ event.stopPropagation()
9176
+ try { event.dataTransfer.dropEffect = 'move' } catch { }
9177
+ }
9178
+ return
9179
+ }
9180
+ if (zone !== 'into' && (drag.source.levelKey || '') !== parentKeyOf(idPath) && (ownerPrefixOf(idPath) || '') !== (drag.source.disk || '')) { clearOver('folder', idPath); return }
9181
+ }
8800
9182
  event.preventDefault()
8801
9183
  event.stopPropagation()
8802
9184
  try { event.dataTransfer.dropEffect = 'move' } catch { }
8803
- setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === path)
9185
+ const reorder = zone !== 'into'
9186
+ const nextOver = reorder
9187
+ ? { kind: 'folder', target: idPath, zone }
9188
+ : { kind: 'folder', target: idPath, zone: 'into' }
9189
+ setDrag(current => (current && current.over && current.over.kind === 'folder' && current.over.target === idPath && current.over.zone === nextOver.zone)
8804
9190
  ? current
8805
- : (current ? { ...current, over: { kind: 'folder', target: path } } : current))
9191
+ : (current ? { ...current, over: nextOver } : current))
8806
9192
  },
8807
9193
  onDrop: (event) => {
9194
+ if (dragMatches('folder')) {
9195
+ event.preventDefault()
9196
+ event.stopPropagation()
9197
+ const zone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath ? drag.over.zone : 'into'
9198
+ if (zone === 'before' || zone === 'after') commitFolderReorder(drag.source, idPath, zone, node)
9199
+ else commitFolderMoveInto(drag.source, idPath)
9200
+ return
9201
+ }
8808
9202
  if (!dragMatches('workspace')) return
8809
9203
  event.preventDefault()
8810
9204
  event.stopPropagation()
8811
- commitWorkspaceMoveInto(path)
9205
+ // The live {over} wins; the geometry is the fallback for a drop that
9206
+ // lands before any hover frame recorded a band on this row.
9207
+ const overZone = drag.over && drag.over.kind === 'folder' && drag.over.target === idPath && (drag.over.zone === 'before' || drag.over.zone === 'after') ? drag.over.zone : null
9208
+ const geoZone = rowZone(event) || 'into'
9209
+ const zone = overZone || geoZone
9210
+ if (zone === 'before' || zone === 'after') {
9211
+ // Same level = plain mixed reorder; another level (same disk
9212
+ // scope) = lift the workspace out of its group into that level.
9213
+ if ((drag.source.levelKey || '') === parentKeyOf(idPath)) commitLevelReorder({ kind: 'workspace', source: drag.source }, { kind: 'folder', target: idPath }, zone)
9214
+ else commitWorkspaceLift(drag.source, idPath, zone)
9215
+ } else commitWorkspaceMoveInto(idPath)
8812
9216
  },
8813
9217
  })
8814
9218
  const nextWorkspaceAfter = (folderPath, workspaceId) => {
@@ -8817,6 +9221,87 @@ window.__ModuleLoader__.load({
8817
9221
  const index = node.workspaces.findIndex(w => w.workspaceId === workspaceId)
8818
9222
  return index === -1 ? undefined : (node.workspaces[index + 1] ? node.workspaces[index + 1].workspaceId : undefined)
8819
9223
  }
9224
+ // v0.36.5 MIXED LEVEL REORDER. One persisted list per level decides the
9225
+ // display order of BOTH kinds: a group name token and a "w:<id>"
9226
+ // workspace token. Writes the list only — the registry keeps its own
9227
+ // (workspace-only) order, because "above that group" is a VIEW position
9228
+ // the registry has no vocabulary for: a workspace title still decides
9229
+ // which group it belongs to, the list only decides where the row sits.
9230
+ // v0.36.7: a group block dropped INTO the container it already lives in
9231
+ // (the framed INTO on its DIRECT parent group row, or on the disk-level
9232
+ // row that owns it) lands at that container's TAIL — the same
9233
+ // "放入=排到最后" a workspace row gets when dropped INTO its own group.
9234
+ // No prefix is rewritten (the block already lives under the right path),
9235
+ // only the level order moves.
9236
+ const moveFolderToLevelEnd = (source) => {
9237
+ const containerKey = source.levelKey || ''
9238
+ const container = folderContainerOf(containerKey)
9239
+ if (!container) { console.warn('[bw] folder move refused: target level not found', containerKey); return false }
9240
+ const token = namePathOf(source.idPath).split('/').pop()
9241
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9242
+ if (!tokens.includes(token)) { console.warn('[bw] folder move refused: group not in this level', token); return false }
9243
+ const next = tokens.filter((entry) => entry !== token)
9244
+ next.push(token)
9245
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, next)
9246
+ return true
9247
+ }
9248
+ const commitFolderMoveToLevelEnd = (source) => {
9249
+ setDrag(null)
9250
+ moveFolderToLevelEnd(source)
9251
+ }
9252
+ const writeLevelOrder = (containerKey, sourceToken, targetToken, zone) => {
9253
+ const container = folderContainerOf(containerKey)
9254
+ if (!container) { console.warn('[bw] level reorder refused: container not found', containerKey); return false }
9255
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9256
+ const at = tokens.indexOf(sourceToken)
9257
+ if (at === -1) { console.warn('[bw] level reorder refused: source not in this level', sourceToken); return false }
9258
+ tokens.splice(at, 1)
9259
+ const targetAt = tokens.indexOf(targetToken)
9260
+ if (targetAt === -1) { console.warn('[bw] level reorder refused: target not in this level', targetToken); return false }
9261
+ tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
9262
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
9263
+ return true
9264
+ }
9265
+ // v0.36.6 LIFT writes: the source token does NOT exist in the target
9266
+ // level yet (that is what a lift is), so writeLevelOrder's
9267
+ // "source must already be here" precondition does not apply — the token
9268
+ // is spliced straight in beside the target group.
9269
+ const insertLevelToken = (containerKey, sourceToken, targetToken, zone) => {
9270
+ const container = folderContainerOf(containerKey)
9271
+ if (!container) { console.warn('[bw] workspace lift refused: target level not found', containerKey); return false }
9272
+ const tokens = levelSeqOf(container).map(levelTokenOf).filter((token) => token !== sourceToken)
9273
+ const targetAt = tokens.indexOf(targetToken)
9274
+ if (targetAt === -1) { console.warn('[bw] workspace lift refused: target group not in this level', targetToken); return false }
9275
+ tokens.splice(zone === 'after' ? targetAt + 1 : targetAt, 0, sourceToken)
9276
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens)
9277
+ return true
9278
+ }
9279
+ // The level a lifted workspace LEFT keeps a stale "w:<id>" token
9280
+ // otherwise (buildTree skips it harmlessly, but the list would grow
9281
+ // forever and a later re-group could resurrect the old position).
9282
+ const removeLevelToken = (containerKey, token) => {
9283
+ const container = folderContainerOf(containerKey)
9284
+ if (!container) return
9285
+ const tokens = levelSeqOf(container).map(levelTokenOf)
9286
+ if (!tokens.includes(token)) return
9287
+ if (typeof actions.setFolderOrder === 'function') actions.setFolderOrder(containerKey, tokens.filter((entry) => entry !== token))
9288
+ }
9289
+ // Shared commit for both mixed directions — a workspace sliding beside a
9290
+ // group row, and a group block sliding beside a workspace row. The
9291
+ // target names the level it belongs to; the hover gate already refused
9292
+ // cross-level targets and this re-checks it as belt and braces.
9293
+ const commitLevelReorder = (sourceRef, targetRef, zone) => {
9294
+ setDrag(null)
9295
+ if (!canDragWorkspace) { console.warn('[bw] level reorder refused: workspace reorder unavailable on this host'); return }
9296
+ const wsSource = sourceRef.kind === 'workspace'
9297
+ const sourceToken = wsSource ? 'w:' + sourceRef.source.workspaceId : namePathOf(sourceRef.source.idPath).split('/').pop()
9298
+ const folderTarget = targetRef.kind === 'folder'
9299
+ const targetToken = folderTarget ? namePathOf(targetRef.target).split('/').pop() : 'w:' + targetRef.workspace.workspaceId
9300
+ const containerKey = folderTarget ? parentKeyOf(targetRef.target) : (targetRef.workspace.levelKey || '')
9301
+ const sourceLevelKey = sourceRef.source.levelKey || ''
9302
+ if (sourceLevelKey !== containerKey) { console.warn('[bw] level reorder refused: cross-level target', sourceLevelKey, containerKey); return }
9303
+ writeLevelOrder(containerKey, sourceToken, targetToken, zone)
9304
+ }
8820
9305
  const commitWorkspaceDrop = (targetWorkspace, half) => {
8821
9306
  const source = drag.source
8822
9307
  setDrag(null)
@@ -8830,6 +9315,16 @@ window.__ModuleLoader__.load({
8830
9315
  // cross-level drop only re-groups the title (the registry append
8831
9316
  // lands it at the end of ITS disk level).
8832
9317
  const sameDisk = source.disk === diskParentMap.get(targetWorkspace.workspaceId)
9318
+ // v0.36.6: a cross-LEVEL drop on a workspace row's half is a PLACEMENT,
9319
+ // not a re-group. The user reported "the insert line shows but the row
9320
+ // lands somewhere else": renaming into the target's group moved the row
9321
+ // into that group's block at whatever position the group already had.
9322
+ // Lifting to the target row's own level and anchoring at that row is
9323
+ // what makes the drop land where the line was drawn.
9324
+ if (workspaceSlash && (targetWorkspace.levelKey || '') !== (source.levelKey || '')) {
9325
+ commitWorkspaceLift(source, targetWorkspace.levelKey || '', half === 'after' ? 'after' : 'before', targetWorkspace.workspaceId)
9326
+ return
9327
+ }
8833
9328
  const chain = sameFolder
8834
9329
  ? Promise.resolve()
8835
9330
  : Promise.resolve().then(() => {
@@ -8843,6 +9338,69 @@ window.__ModuleLoader__.load({
8843
9338
  : targetWorkspace.workspaceId)
8844
9339
  chain
8845
9340
  .then(() => (anchor !== undefined ? insertWorkspaceBefore(source.workspaceId, anchor) : insertWorkspaceBefore(source.workspaceId)))
9341
+ // v0.36.5: once a level carries a MIXED list (it interleaves a bare
9342
+ // workspace with its groups), the registry edit above no longer
9343
+ // moves the row — rewrite the list too, keeping every other token.
9344
+ .then(() => {
9345
+ const levelKey = targetWorkspace.levelKey || ''
9346
+ const container = folderContainerOf(levelKey)
9347
+ const tokens = container ? levelSeqOf(container).map(levelTokenOf) : []
9348
+ if (tokens.some((token) => token.startsWith('w:'))) {
9349
+ writeLevelOrder(levelKey, 'w:' + source.workspaceId, 'w:' + targetWorkspace.workspaceId, half === 'after' ? 'after' : 'before')
9350
+ }
9351
+ })
9352
+ .catch(fail)
9353
+ }
9354
+ // v0.36.6 LIFT. Dropping a bare workspace row on a group's EDGE band
9355
+ // from a DIFFERENT level means: take the row out of its group, give it
9356
+ // the target level's name prefix and place it right beside that group.
9357
+ // The user's report was "I can never insert it as the first row" — the
9358
+ // gesture existed visually but every path landed it inside the group
9359
+ // (move-into) at the tail. The lift is a rename + a level token, never a
9360
+ // disk move, so the caller has already checked the disk scope.
9361
+ const commitWorkspaceLift = (source, targetIdPath, zone, anchorWorkspaceId) => {
9362
+ setDrag(null)
9363
+ if (!canDragWorkspace) { console.warn('[bw] workspace lift refused: workspace reorder unavailable on this host'); return }
9364
+ const targetParentKey = parentKeyOf(targetIdPath)
9365
+ if (!folderContainerOf(targetParentKey)) { console.warn('[bw] workspace lift refused: target level not found', targetParentKey); return }
9366
+ const cutAt = targetParentKey.lastIndexOf('//')
9367
+ const levelNamePath = cutAt === -1 ? targetParentKey : targetParentKey.slice(cutAt + 2)
9368
+ const sourceKey = source.levelKey || ''
9369
+ const targetNamePath = namePathOf(targetIdPath)
9370
+ const targetNode = findTreeNode(rawTree, targetNamePath)
9371
+ const targetMembers = targetNode ? collectMemberIds(targetNode) : []
9372
+ const registryIds = (items || []).map((w) => w.workspaceId)
9373
+ // after = insert before whatever follows the target block in the
9374
+ // registry (undefined = append). before = before the block's head.
9375
+ // A row-anchored lift (dropped on a workspace row's half) anchors at
9376
+ // that row itself, not at the head/tail of its whole group block.
9377
+ let anchor
9378
+ if (anchorWorkspaceId) {
9379
+ if (zone === 'before') {
9380
+ anchor = anchorWorkspaceId
9381
+ } else {
9382
+ const rowAt = registryIds.indexOf(anchorWorkspaceId)
9383
+ anchor = rowAt === -1 ? undefined : registryIds[rowAt + 1]
9384
+ }
9385
+ } else if (zone === 'before') {
9386
+ anchor = targetMembers[0]
9387
+ } else if (targetMembers.length) {
9388
+ const tailAt = registryIds.indexOf(targetMembers[targetMembers.length - 1])
9389
+ anchor = tailAt === -1 ? undefined : registryIds[tailAt + 1]
9390
+ }
9391
+ const newTitle = levelNamePath !== '' ? levelNamePath + '/' + source.leaf : source.leaf
9392
+ const sameNameLevel = (source.folderPath || '') === levelNamePath
9393
+ Promise.resolve()
9394
+ .then(() => (sameNameLevel ? Promise.resolve() : renameWorkspace(source.workspaceId, newTitle)))
9395
+ .then(() => insertWorkspaceBefore(source.workspaceId, anchor))
9396
+ .then(() => {
9397
+ // The level token is always a GROUP token: a workspace row dropped on
9398
+ // from another level is painted beside the group that row lives in,
9399
+ // so the token to place it next to is that group's name in the
9400
+ // parent level's list (the row's own "w:" token lives one level in).
9401
+ insertLevelToken(targetParentKey, 'w:' + source.workspaceId, targetNamePath.split('/').pop(), zone)
9402
+ if (sourceKey !== targetParentKey) removeLevelToken(sourceKey, 'w:' + source.workspaceId)
9403
+ })
8846
9404
  .catch(fail)
8847
9405
  }
8848
9406
  const commitWorkspaceMoveInto = (folderIdPath) => {
@@ -8859,6 +9417,102 @@ window.__ModuleLoader__.load({
8859
9417
  .then(() => insertWorkspaceBefore(source.workspaceId))
8860
9418
  .catch(fail)
8861
9419
  }
9420
+ // Group drag (v0.36.0): moving a folder rewrites EVERY member's title
9421
+ // under the target prefix — the same batch rename the rename-group
9422
+ // dialog (submitFolderRename) performs. Titles are global name paths,
9423
+ // so the member sweep is prefix-based over all items (same known
9424
+ // limitation as that dialog: same-named groups on different disk
9425
+ // levels move together). Self / descendant targets are already
9426
+ // refused at hover time; re-checked here as belt and braces.
9427
+ const commitFolderMoveInto = (source, targetIdPath) => {
9428
+ setDrag(null)
9429
+ const targetPath = namePathOf(targetIdPath)
9430
+ // v0.36.7: the DIRECT parent is the container the group already lives
9431
+ // in. The hover already showed the framed INTO there, so "放进那个
9432
+ // 组" means "move to the end of that group" — no rename involved.
9433
+ // Checked BEFORE the ancestor refusal below.
9434
+ const cutAt = source.path.lastIndexOf('/')
9435
+ const directParent = cutAt === -1 ? '' : source.path.slice(0, cutAt)
9436
+ if (targetPath === directParent) { moveFolderToLevelEnd(source); return }
9437
+ // An ANCESTOR target means the group already lives there: a prefix
9438
+ // rewrite would duplicate a segment (工作/A → 工作/工作/A). Refuse.
9439
+ if (source.path === targetPath || targetPath.startsWith(source.path + '/') || (targetPath !== '' && source.path.startsWith(targetPath + '/'))) { console.warn('[bw] folder move refused: target is self, descendant or ancestor of', source.path); return }
9440
+ const prefix = source.path + '/'
9441
+ const affected = (items || []).filter((w) => String(w.title || '').startsWith(prefix))
9442
+ if (!affected.length) { console.warn('[bw] folder move refused: no members under', source.path); return }
9443
+ Promise.resolve()
9444
+ .then(async () => {
9445
+ for (const w of affected) {
9446
+ await renameWorkspace(w.workspaceId, targetPath + String(w.title).slice(source.path.length))
9447
+ }
9448
+ })
9449
+ // (v0.36.4) NO automatic expand after a move-into. v0.36.3 opened
9450
+ // the target group here so the move would be visible, but on the
9451
+ // real tree a drop in the middle band is exactly the "变下级"
9452
+ // gesture, and auto-opening made that read as "又被撑开了".
9453
+ // The row the user just dropped into stays as it was; the moved
9454
+ // members appear under it once the user opens that group.
9455
+ .catch(fail)
9456
+ }
9457
+ // Group REORDER (v0.36.0): a name group's position IS the registry
9458
+ // order of its members, so reordering = sliding the whole member
9459
+ // block (prefixes untouched) to just before / after the target
9460
+ // group's block. Anchored like commitWorkspaceDrop: before = the
9461
+ // target's first member, after = whatever follows the target's last
9462
+ // member (undefined = block-append at the level tail). Hover only
9463
+ // arms this between same-level groups (identical owner prefix);
9464
+ // re-checked here as belt and braces.
9465
+ const commitFolderReorder = (source, targetIdPath, zone, targetNode) => {
9466
+ setDrag(null)
9467
+ if (!canDragWorkspace) { console.warn('[bw] folder reorder refused: workspace reorder unavailable on this host'); return }
9468
+ if (ownerPrefixOf(source.idPath) !== ownerPrefixOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-level target'); return }
9469
+ // v0.36.4: SIBLINGS ONLY. A group's rendered position is decided by
9470
+ // its parent's order list, so a target that hangs off a different
9471
+ // parent cannot place it at all.
9472
+ if (parentKeyOf(source.idPath) !== parentKeyOf(targetIdPath)) { console.warn('[bw] folder reorder refused: cross-parent target'); return }
9473
+ const sourceIds = source.memberIds || []
9474
+ if (!sourceIds.length) { console.warn('[bw] folder reorder refused: empty source block'); return }
9475
+ // Whole-block anchors: the target's members include its nested
9476
+ // sub-groups (a plain .workspaces read misses container groups and
9477
+ // silently returned on them — the "drop did nothing" report).
9478
+ // v0.36.3 drag-out: the source block IS part of the target's block
9479
+ // (dropping a nested group onto its parent), so the source subtree
9480
+ // is filtered out before anchoring — otherwise the anchor lands
9481
+ // inside the moving subtree and the drop no-ops or oscillates.
9482
+ const sourceSet = new Set(sourceIds)
9483
+ const targetMembers = collectMemberIds(targetNode).filter((id) => !sourceSet.has(id))
9484
+ if (!targetMembers.length) { console.warn('[bw] folder reorder refused: target has no members to anchor against'); return }
9485
+ // before = the surviving block's first member; after = its last
9486
+ // member (inserting before it = right after the block). Anchoring
9487
+ // on the block tail itself replaces nextWorkspaceAfter, whose
9488
+ // node.workspaces lookup missed nested tails (findIndex -1 →
9489
+ // silent append).
9490
+ const anchor = zone === 'after'
9491
+ ? targetMembers[targetMembers.length - 1]
9492
+ : targetMembers[0]
9493
+ // v0.36.4: THE DISPLAYED ORDER IS PERSISTED HERE. Group order used to
9494
+ // follow buildTree's name sort, so the registry edit below changed
9495
+ // nothing the user could see ("拖了没反应"). folderOrder[parentKey]
9496
+ // is the sibling name list buildTree sorts by; write it first so the
9497
+ // drop is visible immediately, then sync the registry for the
9498
+ // workspace order other surfaces read.
9499
+ const orderParent = parentKeyOf(source.idPath)
9500
+ const sourceName = namePathOf(source.idPath).split('/').pop()
9501
+ const targetName = namePathOf(targetIdPath).split('/').pop()
9502
+ // v0.36.5: write the MIXED token list (group names + w:<id> tokens),
9503
+ // not just the folder names — a level that already interleaves a bare
9504
+ // workspace with its groups must keep those workspace positions.
9505
+ writeLevelOrder(orderParent, sourceName, targetName, zone)
9506
+ Promise.resolve()
9507
+ .then(async () => {
9508
+ if (anchor === undefined) {
9509
+ for (const id of sourceIds) await insertWorkspaceBefore(id)
9510
+ } else {
9511
+ for (const id of sourceIds) await insertWorkspaceBefore(id, anchor)
9512
+ }
9513
+ })
9514
+ .catch(fail)
9515
+ }
8862
9516
 
8863
9517
  /* ---------------------- session drag & drop --------------------- */
8864
9518
 
@@ -8986,6 +9640,10 @@ window.__ModuleLoader__.load({
8986
9640
  .catch(fail)
8987
9641
  }
8988
9642
  const wsRootDropActive = (workspaceId) => dragMatches('session') && drag.over !== null && drag.over.kind === 'ws-root' && drag.over.target === workspaceId
9643
+ // v0.36.7: a group block hovering the disk-level row that OWNS it lights
9644
+ // the SAME framed INTO a workspace row gets on a group row — the visual
9645
+ // is one language: edge bands reorder, the middle band "puts inside".
9646
+ const wsGroupIntoActive = (workspaceId) => dragMatches('folder') && drag.over !== null && drag.over.kind === 'ws-level' && drag.over.target === workspaceId
8989
9647
  const commitSessionMoveInto = (workspaceId, groupPath) => {
8990
9648
  const source = drag.source
8991
9649
  setDrag(null)
@@ -9245,16 +9903,14 @@ window.__ModuleLoader__.load({
9245
9903
  // SEE the new session appear, even if the row was collapsed.
9246
9904
  onStart: () => { actions.setSessionsExpanded(workspace.workspaceId, true); startSession(workspace.workspaceId) },
9247
9905
  onContextMenu: (e) => openCtx('workspace', workspace, e),
9248
- // Official Rows.tsx: the ellipsis button anchors the row's own
9249
- // menu; the ctx surface stays the right-click path. While the
9250
- // menu is open the row pins (hover fill + visible actions),
9251
- // matching the official menuOpen state.
9252
- onMenu: (e) => openCtx('workspace', workspace, e),
9906
+ // The in-row ellipsis is retired; the ctx surface stays the
9907
+ // right-click path. While the menu is open the row still pins
9908
+ // (hover fill + visible actions), matching the official menuOpen
9909
+ // state.
9253
9910
  menuOpen: !!(ctx && ctx.kind === 'workspace' && ctx.payload && ctx.payload.workspaceId === workspace.workspaceId),
9254
- menuAria: officialText('actions.workspace.aria', { name: workspace.title || workspace.leaf }) || undefined,
9255
9911
  hoverBody: workspaceHoverBody,
9256
9912
  dropHalf: wsDropHalf(workspace.workspaceId),
9257
- dropInto: wsRootDropActive(workspace.workspaceId),
9913
+ dropInto: wsRootDropActive(workspace.workspaceId) || wsGroupIntoActive(workspace.workspaceId),
9258
9914
  dragEvents: workspaceDragEvents(workspace),
9259
9915
  custStyle: rowStyleOf('workspace:' + workspace.workspaceId),
9260
9916
  iconMode: (styleEntry('workspace:' + workspace.workspaceId) || {}).icon || 'solid',
@@ -9298,8 +9954,11 @@ window.__ModuleLoader__.load({
9298
9954
  : undefined
9299
9955
  const renderLevelRows = (level, depth) => {
9300
9956
  const rows = []
9301
- for (const folder of level.folders) rows.push(...renderPlainFolder(folder, depth))
9302
- for (const workspace of level.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth, wsPulseOf(workspace)))
9957
+ for (const item of levelSeqOf(level)) {
9958
+ rows.push(...(item.kind === 'ws'
9959
+ ? renderWorkspaceEntry({ workspace: item.entry }, depth, wsPulseOf(item.entry))
9960
+ : renderPlainFolder(item.node, depth)))
9961
+ }
9303
9962
  return rows
9304
9963
  }
9305
9964
 
@@ -9320,7 +9979,8 @@ window.__ModuleLoader__.load({
9320
9979
  onToggle: () => { if (!searching) actions.setExpanded(idPath, !expanded) },
9321
9980
  onContextMenu: (e) => openCtx('folder', { path: node.path, name: node.name, idPath, scope: node.scope, empty: countWorkspaces(node) === 0 }, e),
9322
9981
  dropInto: wsDropInto(idPath),
9323
- dragEvents: folderDropEvents(idPath),
9982
+ dropSlot: wsFolderSlotOf(idPath),
9983
+ dragEvents: folderDropEvents(idPath, node.path, node),
9324
9984
  custStyle: rowStyleOf('folder:' + idPath),
9325
9985
  iconMode: (styleEntry('folder:' + idPath) || {}).icon || 'solid',
9326
9986
  pulse: expanded ? null : folderPulseOf(node),
@@ -9328,8 +9988,11 @@ window.__ModuleLoader__.load({
9328
9988
  t,
9329
9989
  })]
9330
9990
  if (expanded) {
9331
- for (const child of node.folders) rows.push(...renderPlainFolder(child, depth + 1))
9332
- for (const workspace of node.workspaces) rows.push(...renderWorkspaceEntry({ workspace }, depth + 1, wsPulseOf(workspace)))
9991
+ for (const item of levelSeqOf(node)) {
9992
+ rows.push(...(item.kind === 'ws'
9993
+ ? renderWorkspaceEntry({ workspace: item.entry }, depth + 1, wsPulseOf(item.entry))
9994
+ : renderPlainFolder(item.node, depth + 1)))
9995
+ }
9333
9996
  }
9334
9997
  return rows
9335
9998
  }
@@ -9384,8 +10047,11 @@ window.__ModuleLoader__.load({
9384
10047
  }
9385
10048
  bodyRows.push(...renderUngroupedBlock())
9386
10049
  } else {
9387
- for (const folder of tree.folders) bodyRows.push(...renderPlainFolder(folder, 0))
9388
- for (const workspace of tree.workspaces) bodyRows.push(...renderWorkspaceEntry({ workspace }, 0, wsPulseOf(workspace)))
10050
+ for (const item of levelSeqOf(tree)) {
10051
+ bodyRows.push(...(item.kind === 'ws'
10052
+ ? renderWorkspaceEntry({ workspace: item.entry }, 0, wsPulseOf(item.entry))
10053
+ : renderPlainFolder(item.node, 0)))
10054
+ }
9389
10055
  bodyRows.push(...renderUngroupedBlock())
9390
10056
  }
9391
10057
  const isEmpty = bodyRows.length === 0
@@ -9564,8 +10230,8 @@ window.__ModuleLoader__.load({
9564
10230
  ]
9565
10231
  if (ctx.kind === 'session') {
9566
10232
  // The shared builder owns the session list (see
9567
- // sessionMenuEntriesFor): the row's own "..." menu renders the
9568
- // exact same entries, so the two surfaces cannot drift.
10233
+ // sessionMenuEntriesFor): the ctx menu and any future surface share
10234
+ // it, so the entries cannot drift.
9569
10235
  return sessionMenuEntriesFor(ctx.payload || {})
9570
10236
  }
9571
10237
  return [
@@ -10258,33 +10924,54 @@ window.__ModuleLoader__.load({
10258
10924
  if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session search failed')
10259
10925
  return result.value
10260
10926
  }
10261
- // dsh 0.1.6-alpha.2 retention contract: rename borrows through
10262
- // sessions.using with an explicit source — the old plain binding(id)
10263
- // only ever saw already-retained sessions, so renaming a closed row
10264
- // reported "unknown session". Fork and open go through the uiWorkspace
10265
- // navigation service: ISessions no longer carries open() at all
10266
- // (navigation belongs to the view owner, replaceMain).
10927
+ // Rename performance (issue #14): sessions.using retains the session and
10928
+ // awaits reference.ready FIRST, so renaming a closed 5MB row stalled ~6s
10929
+ // loading its whole history just to send a 20ms rename RPC (the rename
10930
+ // itself is independent of the transcript). Tiered, cheapest first:
10267
10931
  //
10268
- // Host-era compat (0.1.5-rc.x hosts ship ISessions.binding only — the
10269
- // 0.1.6-alpha.2 contract replaced it with using): calling sessions.using
10270
- // there died with "sessions.using is not a function", which took down
10271
- // every rename path (row dialog, session-group batch, group move). Prefer
10272
- // the new contract, fall back to the old one, and only then fail loud.
10932
+ // 1. Open session → borrow binding directly. On 0.1.6+ hosts binding(id)
10933
+ // only sees retained scopes (service.ts:539 `scopes.get(id)?.binding`,
10934
+ // undefined when closed), so "a binding comes back" IS the open test;
10935
+ // on 0.1.5-rc.x hosts every row is visible, identical to the old
10936
+ // fallback. Same SessionBinding object the using path borrows, so
10937
+ // title projection / list refresh behave exactly the same.
10938
+ // 2. Closed session → sessions.manager.rename(id, title): "Rename a
10939
+ // Session ... without opening its history" (manager.ts:578) — a bare
10940
+ // remote.session.rename plus the title projection. manager is a
10941
+ // TS-private field of ClientSessions (service.ts:245), reachable at
10942
+ // runtime; feature-probed so hosts without it skip the tier.
10943
+ // 3. Fallback → the 0.1.6-alpha.2 retention contract: sessions.using
10944
+ // with an explicit source (slow — loads history — but the only legal
10945
+ // path on hosts predating manager.rename). 0.1.5-rc.x hosts ship
10946
+ // ISessions.binding only; tier 1 already served them. Then fail loud.
10947
+ // Fork and open go through the uiWorkspace navigation service: ISessions
10948
+ // no longer carries open() at all (navigation belongs to the view owner,
10949
+ // replaceMain).
10273
10950
  const renameSession = async (sessionId, title) => {
10951
+ const fail = (result) => new Error(result && result.error ? result.error.message : 'session rename failed')
10952
+ const binding = typeof sessions.binding === 'function' ? sessions.binding(sessionId) : undefined
10953
+ const face = binding ? binding.session : undefined
10954
+ if (face && typeof face.rename === 'function') {
10955
+ const result = await face.rename(title)
10956
+ if (!result || !result.ok) throw fail(result)
10957
+ return
10958
+ }
10959
+ const manager = sessions.manager
10960
+ if (manager && typeof manager.rename === 'function') {
10961
+ const result = await manager.rename(sessionId, title)
10962
+ if (!result || !result.ok) throw fail(result)
10963
+ return
10964
+ }
10274
10965
  if (typeof sessions.using === 'function') {
10275
10966
  const result = await sessions.using(
10276
10967
  sessionId,
10277
10968
  { source: 'workspaceOperation' },
10278
10969
  (reference) => reference.binding.session.rename(title),
10279
10970
  )
10280
- if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
10971
+ if (!result || !result.ok) throw fail(result)
10281
10972
  return
10282
10973
  }
10283
- const binding = typeof sessions.binding === 'function' ? sessions.binding(sessionId) : undefined
10284
- const face = binding ? binding.session : undefined
10285
- if (!face || typeof face.rename !== 'function') throw new Error('session rename is unavailable on this host')
10286
- const result = await face.rename(title)
10287
- if (!result || !result.ok) throw new Error(result && result.error ? result.error.message : 'session rename failed')
10974
+ throw new Error('session rename is unavailable on this host')
10288
10975
  }
10289
10976
  const forkSession = (sessionId) => {
10290
10977
  uiWorkspace.forkSession(sessionId)