@aryacode/dsh-aryacode-polish 0.11.2 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.1] - 2026-10-28
4
+
5
+ ### Added
6
+
7
+ - **Rename a workspace in place.** The workspace menu opens an input under the search
8
+ box; Enter (or blurring) sends `workspaces.rename`, Escape abandons it. Renaming to
9
+ `Work/Client A` is also how a workspace joins a folder.
10
+
11
+ ### Fixed
12
+
13
+ - `planReorder` ignores ids the Host no longer has, so a saved order that names a
14
+ deleted workspace cannot shuffle the survivors into the wrong slots.
15
+
16
+ ## [0.13.0] - 2026-10-28
17
+
18
+ ### Added
19
+
20
+ - **Folders from titles.** A workspace named `Work/Client A` files itself under a
21
+ collapsible `Work` row, with the folder's session count on the right. The grouping is
22
+ derived from the label and written nowhere, so renaming the workspace moves it.
23
+ - **Order presets.** The workspace menu can "Remember this order" and later "Restore
24
+ saved order", which replays the Host's only reorder verb — `insertBefore` — through a
25
+ pure `planReorder(current, wanted)` plan, stored under
26
+ `aryacode-polish:workspace-order.v1`.
27
+
28
+ ## [0.12.1] - 2026-10-28
29
+
30
+ ### Fixed
31
+
32
+ - The Hero list now only appears when the workspace chip is open, and it paints a
33
+ card of its own instead of an unstyled list floating over the Hero copy.
34
+
35
+ ## [0.12.0] - 2026-10-28
36
+
37
+ ### Added
38
+
39
+ - **Colour and weight per workspace.** Right-click a workspace → "Colour and
40
+ weight…" paints it one of six tones (slate, blue, green, amber, rose, violet) or
41
+ none, and can make its label bold. The choice is stored under
42
+ `aryacode-polish:workspace-appearance` in the app's own storage, so it survives a
43
+ restart, and a broken or missing store degrades to the plain tree instead of
44
+ throwing.
45
+ - **The same workspaces in the Hero picker.** The blank-session Hero now offers this
46
+ bundle's workspace list (tone dots, bold labels, session counts) instead of the
47
+ stock picker, through `conversation.hero.workspace` at priority -1 — same data,
48
+ same colours, one switch.
49
+
3
50
  ## [0.11.2] - 2026-10-28
4
51
 
5
52
  ### Verified
@@ -382,6 +382,155 @@ export function moveBefore(list, movingId, beforeId) {
382
382
  return next
383
383
  }
384
384
 
385
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
+ const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
387
+
388
+ /**
389
+ * A workspace whose label carries a path ("Work/Client A") files itself under
390
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
391
+ * written to the Host and renaming a workspace is enough to move it between
392
+ * folders — the same idea as the reference plugin, without its "/" toggle.
393
+ */
394
+ export function folderKey(workspace) {
395
+ const label = workspaceLabel(workspace)
396
+ const cut = label.lastIndexOf('/')
397
+ if (cut <= 0 || cut === label.length - 1) return undefined
398
+ return label.slice(0, cut).trim() || undefined
399
+ }
400
+
401
+ export function readOrder(storage) {
402
+ try {
403
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
404
+ if (!store) return []
405
+ const raw = store.getItem(ORDER_KEY)
406
+ if (!raw) return []
407
+ const parsed = JSON.parse(raw)
408
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
409
+ } catch {
410
+ return []
411
+ }
412
+ }
413
+
414
+ export function writeOrder(ids, storage) {
415
+ try {
416
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
417
+ if (!store) return
418
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
419
+ } catch {
420
+ /* storage is a convenience here, never a requirement */
421
+ }
422
+ }
423
+
424
+ /**
425
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
426
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
427
+ * each workspace in front of whatever currently sits at that slot.
428
+ */
429
+ export function planReorder(currentIds, wantedIds) {
430
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
431
+ const ops = []
432
+ // Only ids the Host still has can move; a saved order that names a deleted
433
+ // workspace must not shuffle the rest into the wrong slots.
434
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
435
+ for (let i = 0; i < wanted.length; i += 1) {
436
+ const id = wanted[i]
437
+ const from = current.indexOf(id)
438
+ if (from === -1 || from === i) continue
439
+ const beforeId = current[i]
440
+ ops.push({ movingId: id, beforeId })
441
+ current.splice(from, 1)
442
+ current.splice(i, 0, id)
443
+ }
444
+ return ops
445
+ }
446
+
447
+ const WS_FOLDER_ROW = {
448
+ display: 'flex',
449
+ alignItems: 'center',
450
+ gap: '5px',
451
+ padding: '3px 6px 2px',
452
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
453
+ letterSpacing: '0.02em',
454
+ textTransform: 'uppercase',
455
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
456
+ cursor: 'pointer',
457
+ userSelect: 'none',
458
+ }
459
+
460
+ export const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
461
+
462
+ export const TONE_COLOR = {
463
+ slate: '#8a8f98',
464
+ blue: '#4a72d0',
465
+ green: '#2f9e6b',
466
+ amber: '#c8892f',
467
+ rose: '#c2496a',
468
+ violet: '#7a5cc4',
469
+ }
470
+
471
+ export function appearanceOf(map, workspaceId) {
472
+ const entry = (map && map[workspaceId]) || {}
473
+ return {
474
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
475
+ strong: entry.strong === true,
476
+ }
477
+ }
478
+
479
+ export function withAppearance(map, workspaceId, patch) {
480
+ const next = Object.assign({}, map || {})
481
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
482
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
483
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
484
+ return next
485
+ }
486
+
487
+ export function readAppearance(storage) {
488
+ try {
489
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
490
+ if (!store) return {}
491
+ const raw = store.getItem(LOOK_KEY)
492
+ if (!raw) return {}
493
+ const parsed = JSON.parse(raw)
494
+ return parsed && typeof parsed === 'object' ? parsed : {}
495
+ } catch {
496
+ return {}
497
+ }
498
+ }
499
+
500
+ export function writeAppearance(map, storage) {
501
+ try {
502
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
503
+ if (!store) return
504
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
505
+ } catch {
506
+ /* a locked-down webview must not break the tree */
507
+ }
508
+ }
509
+
510
+ const WS_TONE = {
511
+ flex: 'none',
512
+ width: '6px',
513
+ height: '6px',
514
+ borderRadius: '50%',
515
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
516
+ }
517
+
518
+ const WS_SWATCH = {
519
+ width: '18px',
520
+ height: '18px',
521
+ borderRadius: '50%',
522
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
523
+ cursor: 'pointer',
524
+ padding: 0,
525
+ }
526
+
527
+ const WS_PAINT_ROW = {
528
+ display: 'flex',
529
+ alignItems: 'center',
530
+ gap: '6px',
531
+ padding: '6px 8px',
532
+ }
533
+
385
534
  const WS_FILTER = {
386
535
  margin: '2px 2px 6px',
387
536
  padding: '4px 8px',
@@ -447,6 +596,30 @@ export function BetterWorkspace(props) {
447
596
  const [hover, setHover] = React.useState(null)
448
597
  const [drag, setDrag] = React.useState(null)
449
598
  const [dropping, setDropping] = React.useState(null)
599
+ const [look, setLook] = React.useState(() => readAppearance())
600
+ const [folders, setFolders] = React.useState({})
601
+ const [renaming, setRenaming] = React.useState(null)
602
+ const commitRename = async () => {
603
+ const draft = renaming
604
+ setRenaming(null)
605
+ if (!draft || !services.renameWorkspace) return
606
+ const title = String(draft.value || '').trim()
607
+ if (title === '' || title === draft.label) return
608
+ try {
609
+ await services.renameWorkspace(draft.workspaceId, title)
610
+ setNotice('Renamed to ' + title + '.')
611
+ } catch {
612
+ setNotice('Could not rename that workspace.')
613
+ }
614
+ }
615
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
616
+ const paintLook = (workspaceId, patch) => {
617
+ setLook((prev) => {
618
+ const next = withAppearance(prev, workspaceId, patch)
619
+ writeAppearance(next)
620
+ return next
621
+ })
622
+ }
450
623
  const filterRef = React.useRef(null)
451
624
 
452
625
  const workspaceState = useLive(services.workspacesLive)
@@ -461,7 +634,12 @@ export function BetterWorkspace(props) {
461
634
  )
462
635
  const allRows = workspaceRows(workspaceList, sessionList)
463
636
  const rows = filterTree(allRows, query, pinned)
464
- const walk = flattenRows(rows, expanded, query)
637
+ const folderOf = (workspace) => folderKey(workspace)
638
+ const visibleRows = rows.filter((group) => {
639
+ const key = folderOf(group.workspace)
640
+ return key === undefined || folders[key] !== true
641
+ })
642
+ const walk = flattenRows(visibleRows, expanded, query)
465
643
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
466
644
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
467
645
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -588,6 +766,56 @@ export function BetterWorkspace(props) {
588
766
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
589
767
  },
590
768
  }, 'Copy path'),
769
+ React.createElement('button', {
770
+ type: 'button',
771
+ 'data-polish-ws-rename': 'open',
772
+ style: WS_MENU_ITEM,
773
+ onClick: () => {
774
+ const workspace = menu.workspace
775
+ closeMenu()
776
+ setNotice('')
777
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
778
+ },
779
+ }, 'Rename workspace…'),
780
+ React.createElement('button', {
781
+ type: 'button',
782
+ 'data-polish-order': 'remember',
783
+ style: WS_MENU_ITEM,
784
+ onClick: () => {
785
+ closeMenu()
786
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
787
+ writeOrder(ids)
788
+ setSavedOrder(ids)
789
+ setNotice('Order saved.')
790
+ },
791
+ }, 'Remember this order'),
792
+ savedOrder.length === 0
793
+ ? null
794
+ : React.createElement('button', {
795
+ type: 'button',
796
+ 'data-polish-order': 'restore',
797
+ style: WS_MENU_ITEM,
798
+ onClick: async () => {
799
+ closeMenu()
800
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
801
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
802
+ try {
803
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
804
+ setNotice('Order restored.')
805
+ } catch {
806
+ setNotice('Could not restore that order.')
807
+ }
808
+ },
809
+ }, 'Restore saved order'),
810
+ React.createElement('button', {
811
+ type: 'button',
812
+ 'data-polish-ws-appearance': 'open',
813
+ style: WS_MENU_ITEM,
814
+ onClick: () => {
815
+ const workspace = menu.workspace
816
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
817
+ },
818
+ }, 'Colour and weight…'),
591
819
  React.createElement('button', {
592
820
  type: 'button',
593
821
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -604,6 +832,44 @@ export function BetterWorkspace(props) {
604
832
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
605
833
  : null
606
834
 
835
+ const paintMenu = menu && menu.kind === 'paint'
836
+ ? (() => {
837
+ const workspace = menu.workspace
838
+ const current = appearanceOf(look, workspace.workspaceId)
839
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
840
+ React.createElement('div', { style: WS_PAINT_ROW },
841
+ TONE_ORDER.map((tone) => React.createElement('button', {
842
+ key: tone,
843
+ type: 'button',
844
+ 'data-polish-tone': tone,
845
+ 'aria-label': 'Colour ' + tone,
846
+ title: tone,
847
+ style: Object.assign({}, WS_SWATCH, {
848
+ background: TONE_COLOR[tone],
849
+ boxShadow: current.tone === tone
850
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
851
+ : undefined,
852
+ }),
853
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
854
+ })),
855
+ React.createElement('button', {
856
+ key: 'none',
857
+ type: 'button',
858
+ 'data-polish-tone': 'none',
859
+ 'aria-label': 'No colour',
860
+ title: 'No colour',
861
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
862
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
863
+ })),
864
+ React.createElement('button', {
865
+ type: 'button',
866
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
867
+ style: WS_MENU_ITEM,
868
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
869
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
870
+ })()
871
+ : null
872
+
607
873
  const sessionMenu = menu && menu.kind === 'session'
608
874
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
609
875
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -677,6 +943,26 @@ export function BetterWorkspace(props) {
677
943
  }))
678
944
  }
679
945
 
946
+ if (renaming) {
947
+ children.push(React.createElement('input', {
948
+ key: 'rename',
949
+ 'data-polish-ws-rename-input': 'true',
950
+ autoFocus: true,
951
+ value: renaming.value,
952
+ placeholder: 'Workspace name (use Work/Name to file it)',
953
+ 'aria-label': 'Workspace name',
954
+ style: WS_FILTER,
955
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
956
+ onKeyDown: (event) => {
957
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
958
+ if (event.key !== 'Enter') return
959
+ event.preventDefault()
960
+ void commitRename()
961
+ },
962
+ onBlur: () => { void commitRename() },
963
+ }))
964
+ }
965
+
680
966
  if (rows.length === 0) {
681
967
  children.push(React.createElement('div', {
682
968
  key: 'empty',
@@ -685,14 +971,82 @@ export function BetterWorkspace(props) {
685
971
  }
686
972
 
687
973
  let index = -1
974
+ const announced = {}
975
+
976
+ const sessionRow = (workspace, row, groupIndex) => {
977
+ const rowFocused = cursor === groupIndex
978
+ const rowHovered = hover === 'session:' + row.id
979
+ const lifted = dropping === 'session:' + row.id
980
+ return React.createElement('div', {
981
+ key: 'session:' + row.id,
982
+ 'data-polish-ws-session': row.id,
983
+ draggable: true,
984
+ style: Object.assign({}, WS_ROW, {
985
+ paddingLeft: '24px',
986
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
987
+ background: lifted
988
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
989
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
990
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
991
+ }),
992
+ onClick: () => services.openSession(row.id),
993
+ onContextMenu: (event) => openMenu(event, row, workspace),
994
+ onMouseEnter: () => setHover('session:' + row.id),
995
+ onMouseLeave: () => setHover(null),
996
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
997
+ onDragOver: (event) => {
998
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
999
+ event.preventDefault()
1000
+ setDropping('session:' + row.id)
1001
+ },
1002
+ onDragLeave: () => setDropping(null),
1003
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1004
+ },
1005
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1006
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1007
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1008
+ rowHovered || rowFocused
1009
+ ? React.createElement('button', {
1010
+ type: 'button',
1011
+ 'data-polish-ws-menu': 'session',
1012
+ 'aria-label': 'Session menu',
1013
+ style: WS_ROW_ACTION,
1014
+ onClick: (event) => openMenu(event, row, workspace),
1015
+ }, React.createElement(DotsGlyph))
1016
+ : null)
1017
+ }
1018
+
688
1019
  rows.forEach((group) => {
689
1020
  const workspace = group.workspace
690
1021
  const sessions = group.sessions
1022
+ const folder = folderOf(workspace)
1023
+ const folded = folder !== undefined && folders[folder] === true
1024
+
1025
+ if (folder !== undefined && !announced[folder]) {
1026
+ announced[folder] = true
1027
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1028
+ children.push(React.createElement('div', {
1029
+ key: 'folder:' + folder,
1030
+ 'data-polish-ws-folder': folder,
1031
+ role: 'group',
1032
+ 'aria-label': folder,
1033
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1034
+ style: WS_FOLDER_ROW,
1035
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1036
+ },
1037
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1038
+ React.createElement('span', { style: WS_TITLE }, folder),
1039
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1040
+ }
1041
+
1042
+ if (folded) return
1043
+
691
1044
  index += 1
692
1045
  const open = expanded[workspace.workspaceId] !== false
693
1046
  const focused = cursor === index
694
1047
  const hovered = hover === 'workspace:' + workspace.workspaceId
695
1048
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1049
+ const looks = appearanceOf(look, workspace.workspaceId)
696
1050
 
697
1051
  children.push(React.createElement('div', {
698
1052
  key: 'workspace:' + workspace.workspaceId,
@@ -724,7 +1078,12 @@ export function BetterWorkspace(props) {
724
1078
  },
725
1079
  React.createElement(CaretGlyph, { open }),
726
1080
  React.createElement(FolderGlyph),
727
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1081
+ looks.tone
1082
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1083
+ : null,
1084
+ React.createElement('span', {
1085
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
1086
+ }, workspaceLabel(workspace)),
728
1087
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
729
1088
  hovered || focused
730
1089
  ? React.createElement('button', {
@@ -739,46 +1098,7 @@ export function BetterWorkspace(props) {
739
1098
  if (open) {
740
1099
  sessions.forEach((row) => {
741
1100
  index += 1
742
- const rowFocused = cursor === index
743
- const rowHovered = hover === 'session:' + row.id
744
- const lifted = dropping === 'session:' + row.id
745
- children.push(React.createElement('div', {
746
- key: 'session:' + row.id,
747
- 'data-polish-ws-session': row.id,
748
- draggable: true,
749
- style: Object.assign({}, WS_ROW, {
750
- paddingLeft: '24px',
751
- opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
752
- background: lifted
753
- ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
754
- : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
755
- boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
756
- }),
757
- onClick: () => services.openSession(row.id),
758
- onContextMenu: (event) => openMenu(event, row, workspace),
759
- onMouseEnter: () => setHover('session:' + row.id),
760
- onMouseLeave: () => setHover(null),
761
- onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
762
- onDragOver: (event) => {
763
- if (!drag || drag.kind !== 'session' || drag.id === row.id) return
764
- event.preventDefault()
765
- setDropping('session:' + row.id)
766
- },
767
- onDragLeave: () => setDropping(null),
768
- onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
769
- },
770
- row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
771
- pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
772
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
773
- rowHovered || rowFocused
774
- ? React.createElement('button', {
775
- type: 'button',
776
- 'data-polish-ws-menu': 'session',
777
- 'aria-label': 'Session menu',
778
- style: WS_ROW_ACTION,
779
- onClick: (event) => openMenu(event, row, workspace),
780
- }, React.createElement(DotsGlyph))
781
- : null))
1101
+ children.push(sessionRow(workspace, row, index))
782
1102
  })
783
1103
  }
784
1104
  })
@@ -806,9 +1126,81 @@ export function BetterWorkspace(props) {
806
1126
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
807
1127
  : null,
808
1128
  sessionMenu,
1129
+ paintMenu,
809
1130
  workspaceMenu)
810
1131
  }
811
1132
 
1133
+ const WS_HERO = {
1134
+ display: 'flex',
1135
+ flexDirection: 'column',
1136
+ gap: '1px',
1137
+ minWidth: '220px',
1138
+ maxHeight: '320px',
1139
+ overflowY: 'auto',
1140
+ padding: '4px',
1141
+ }
1142
+
1143
+ const WS_HERO_TITLE = {
1144
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
1145
+ letterSpacing: '0.02em',
1146
+ textTransform: 'uppercase',
1147
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
1148
+ padding: '4px 6px 2px',
1149
+ }
1150
+
1151
+ /**
1152
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
1153
+ * reachable before a session exists — the Hero slot wants a list that calls
1154
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
1155
+ */
1156
+ export function HeroWorkspacePicker(props) {
1157
+ const services = (props && props.services) || {}
1158
+ const workspaceState = useLive(services.workspacesLive)
1159
+ const look = React.useMemo(() => readAppearance(), [])
1160
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
1161
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
1162
+ // the list unconditionally left it floating over the Hero copy.
1163
+ if (!props || props.open !== true || list.length === 0) return null
1164
+ const anchor = props.anchorRef && props.anchorRef.current
1165
+ const spot = anchor && anchor.getBoundingClientRect
1166
+ ? (() => {
1167
+ const box = anchor.getBoundingClientRect()
1168
+ return {
1169
+ position: 'fixed',
1170
+ top: Math.round(box.bottom + 6) + 'px',
1171
+ left: Math.max(8, Math.round(box.left)) + 'px',
1172
+ zIndex: 1200,
1173
+ }
1174
+ })()
1175
+ : {}
1176
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
1177
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
1178
+ list.map((workspace) => {
1179
+ const looks = appearanceOf(look, workspace.workspaceId)
1180
+ return React.createElement('button', {
1181
+ key: workspace.workspaceId,
1182
+ type: 'button',
1183
+ 'data-polish-hero-row': workspace.workspaceId,
1184
+ style: Object.assign({}, WS_ROW, {
1185
+ fontWeight: looks.strong ? 600 : 500,
1186
+ background: 'transparent',
1187
+ border: 'none',
1188
+ textAlign: 'left',
1189
+ }),
1190
+ onClick: () => {
1191
+ if (props.onPick) props.onPick(workspace.workspaceId)
1192
+ if (props.onClose) props.onClose()
1193
+ },
1194
+ },
1195
+ React.createElement(FolderGlyph),
1196
+ looks.tone
1197
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1198
+ : null,
1199
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1200
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1201
+ })), document.body)
1202
+ }
1203
+
812
1204
  /**
813
1205
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
814
1206
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -816,6 +1208,7 @@ export function BetterWorkspace(props) {
816
1208
  */
817
1209
  export function installWorkspaceSeat(ctx, configForms, extras = {}) {
818
1210
  let dispose
1211
+ let disposeHero
819
1212
  let unlisten
820
1213
  const scope = () => {
821
1214
  try {
@@ -858,6 +1251,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
858
1251
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
859
1252
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
860
1253
  removeWorkspace: (id) => seated.workspaces.delete(id),
1254
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
861
1255
  openFolder: async (path) => {
862
1256
  const answer = await seated.remote.session.openWorkspacePath({ path })
863
1257
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
@@ -868,6 +1262,13 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
868
1262
  priority: -1,
869
1263
  inject: () => ({ services }),
870
1264
  }, BetterWorkspace))
1265
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1266
+ // both live and die with the one switch.
1267
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1268
+ name: 'conversation.hero.workspace',
1269
+ priority: -1,
1270
+ inject: () => ({ services }),
1271
+ }, HeroWorkspacePicker))
871
1272
  },
872
1273
  )
873
1274
  } catch (error) {
@@ -880,6 +1281,12 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
880
1281
  install()
881
1282
  return
882
1283
  }
1284
+ if (typeof disposeHero === 'function') {
1285
+ try {
1286
+ disposeHero()
1287
+ } catch {}
1288
+ }
1289
+ disposeHero = undefined
883
1290
  if (typeof dispose === 'function') {
884
1291
  try {
885
1292
  dispose()
@@ -897,6 +1304,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
897
1304
  }
898
1305
  return () => {
899
1306
  if (typeof unlisten === 'function') unlisten()
1307
+ if (typeof disposeHero === 'function') disposeHero()
900
1308
  if (typeof dispose === 'function') dispose()
901
1309
  }
902
1310
  }
@@ -93,3 +93,44 @@ So the tree refuses with "A session stays in its workspace; drag it within that
93
93
  workspace to reorder." rather than attempting a call that throws. If the Host ever
94
94
  exposes attach/detach, the gesture becomes a one-line change here — the drag payload
95
95
  already carries `{ kind: 'session', id, workspaceId }`.
96
+
97
+ ## Colour and weight
98
+
99
+ Right-click a workspace → **Colour and weight…** opens a six-tone palette (slate, blue,
100
+ green, amber, rose, violet) plus "none", and toggles a bold label. The choice is kept in
101
+ the app's own storage under `aryacode-polish:workspace-appearance.v1` as
102
+ `{ [workspaceId]: { tone, strong } }`, so it survives a restart without asking the Host
103
+ to remember anything. `readAppearance`/`writeAppearance` swallow every failure
104
+ (missing storage, quota, a locked-down webview), so a broken store costs the colours
105
+ and nothing else. `appearanceOf` ignores a tone it does not know, which keeps an old
106
+ entry from breaking the row if the palette ever changes.
107
+
108
+ ## The Hero picker
109
+
110
+ The blank-session Hero's workspace chip is the same list, not a second implementation:
111
+ `conversation.hero.workspace` is a `single` slot at the root, so this bundle shadows it
112
+ at `priority: -1` beside `sidebar.workspaces` and both seats come and go with the one
113
+ switch. The slot hands over `{ open, anchorRef, selectedId, onPick, onClose }`: the card
114
+ is portalled to the anchor's bottom-left only while `open` is true (an unconditional
115
+ list floated over the Hero copy), it shows the same tone dots and bold labels as the
116
+ sidebar, and picking a row calls `onPick(workspaceId)` then `onClose()`.
117
+
118
+ ## Folders from titles
119
+
120
+ A workspace whose label carries a path files itself under the prefix: rename
121
+ `Alpha` to `Work/Alpha` (workspace menu → **Rename workspace…**) and a collapsible
122
+ `WORK` row appears, carrying the session count of everything filed inside it. The
123
+ group is derived on every render from `folderKey(workspace)` — nothing is written to the
124
+ Host, nothing is cached, and renaming the workspace out of the prefix puts it back in
125
+ the flat list. `folderKey` refuses a leading slash, a trailing slash and a bare label, so
126
+ ordinary names never grow a folder.
127
+
128
+ ## Order presets
129
+
130
+ **Remember this order** stores the current workspace ids under
131
+ `aryacode-polish:workspace-order.v1`; **Restore saved order** replays them. The Host
132
+ offers exactly one reorder verb (`insertBefore(movingId, beforeId)`), so restoring is
133
+ `planReorder(current, wanted)` — a pure function that walks the wanted order and emits
134
+ one move per workspace that sits in the wrong slot. Ids the Host no longer has are
135
+ filtered out first, so a preset that names a deleted workspace cannot shuffle the
136
+ survivors into the wrong places.
package/lib/client.js CHANGED
@@ -53,7 +53,7 @@ window.__ModuleLoader__.load({
53
53
  ]
54
54
  const ROW_ID = "polish"
55
55
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
56
- const CLIENT_VERSION = "0.11.2"
56
+ const CLIENT_VERSION = "0.13.1"
57
57
 
58
58
  /* html2canvas 1.4.1 — MIT, vendored from vendor/html2canvas.min.js.
59
59
  * A UMD build, so it assigns itself to globalThis; face.js reads it lazily. */
@@ -583,6 +583,155 @@ window.__ModuleLoader__.load({
583
583
  return next
584
584
  }
585
585
 
586
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
587
+ const ORDER_KEY = 'aryacode-polish:workspace-order.v1'
588
+
589
+ /**
590
+ * A workspace whose label carries a path ("Work/Client A") files itself under
591
+ * "Work". The grouping is purely visual and derived from the title, so nothing is
592
+ * written to the Host and renaming a workspace is enough to move it between
593
+ * folders — the same idea as the reference plugin, without its "/" toggle.
594
+ */
595
+ function folderKey(workspace) {
596
+ const label = workspaceLabel(workspace)
597
+ const cut = label.lastIndexOf('/')
598
+ if (cut <= 0 || cut === label.length - 1) return undefined
599
+ return label.slice(0, cut).trim() || undefined
600
+ }
601
+
602
+ function readOrder(storage) {
603
+ try {
604
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
605
+ if (!store) return []
606
+ const raw = store.getItem(ORDER_KEY)
607
+ if (!raw) return []
608
+ const parsed = JSON.parse(raw)
609
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []
610
+ } catch {
611
+ return []
612
+ }
613
+ }
614
+
615
+ function writeOrder(ids, storage) {
616
+ try {
617
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
618
+ if (!store) return
619
+ store.setItem(ORDER_KEY, JSON.stringify(Array.isArray(ids) ? ids : []))
620
+ } catch {
621
+ /* storage is a convenience here, never a requirement */
622
+ }
623
+ }
624
+
625
+ /**
626
+ * The insertBefore calls that turn one order into another. `insertBefore(moving, before)`
627
+ * is the only reorder verb the Host offers, so the plan walks the wanted order and moves
628
+ * each workspace in front of whatever currently sits at that slot.
629
+ */
630
+ function planReorder(currentIds, wantedIds) {
631
+ const current = Array.isArray(currentIds) ? currentIds.slice() : []
632
+ const ops = []
633
+ // Only ids the Host still has can move; a saved order that names a deleted
634
+ // workspace must not shuffle the rest into the wrong slots.
635
+ const wanted = (Array.isArray(wantedIds) ? wantedIds : []).filter((id) => current.indexOf(id) !== -1)
636
+ for (let i = 0; i < wanted.length; i += 1) {
637
+ const id = wanted[i]
638
+ const from = current.indexOf(id)
639
+ if (from === -1 || from === i) continue
640
+ const beforeId = current[i]
641
+ ops.push({ movingId: id, beforeId })
642
+ current.splice(from, 1)
643
+ current.splice(i, 0, id)
644
+ }
645
+ return ops
646
+ }
647
+
648
+ const WS_FOLDER_ROW = {
649
+ display: 'flex',
650
+ alignItems: 'center',
651
+ gap: '5px',
652
+ padding: '3px 6px 2px',
653
+ fontSize: 'calc(' + 'var(--dsh-content-font-size-secondary, 13px)' + ' - 2px)',
654
+ letterSpacing: '0.02em',
655
+ textTransform: 'uppercase',
656
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
657
+ cursor: 'pointer',
658
+ userSelect: 'none',
659
+ }
660
+
661
+ const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
662
+
663
+ const TONE_COLOR = {
664
+ slate: '#8a8f98',
665
+ blue: '#4a72d0',
666
+ green: '#2f9e6b',
667
+ amber: '#c8892f',
668
+ rose: '#c2496a',
669
+ violet: '#7a5cc4',
670
+ }
671
+
672
+ function appearanceOf(map, workspaceId) {
673
+ const entry = (map && map[workspaceId]) || {}
674
+ return {
675
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
676
+ strong: entry.strong === true,
677
+ }
678
+ }
679
+
680
+ function withAppearance(map, workspaceId, patch) {
681
+ const next = Object.assign({}, map || {})
682
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
683
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
684
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
685
+ return next
686
+ }
687
+
688
+ function readAppearance(storage) {
689
+ try {
690
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
691
+ if (!store) return {}
692
+ const raw = store.getItem(LOOK_KEY)
693
+ if (!raw) return {}
694
+ const parsed = JSON.parse(raw)
695
+ return parsed && typeof parsed === 'object' ? parsed : {}
696
+ } catch {
697
+ return {}
698
+ }
699
+ }
700
+
701
+ function writeAppearance(map, storage) {
702
+ try {
703
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
704
+ if (!store) return
705
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
706
+ } catch {
707
+ /* a locked-down webview must not break the tree */
708
+ }
709
+ }
710
+
711
+ const WS_TONE = {
712
+ flex: 'none',
713
+ width: '6px',
714
+ height: '6px',
715
+ borderRadius: '50%',
716
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
717
+ }
718
+
719
+ const WS_SWATCH = {
720
+ width: '18px',
721
+ height: '18px',
722
+ borderRadius: '50%',
723
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
724
+ cursor: 'pointer',
725
+ padding: 0,
726
+ }
727
+
728
+ const WS_PAINT_ROW = {
729
+ display: 'flex',
730
+ alignItems: 'center',
731
+ gap: '6px',
732
+ padding: '6px 8px',
733
+ }
734
+
586
735
  const WS_FILTER = {
587
736
  margin: '2px 2px 6px',
588
737
  padding: '4px 8px',
@@ -648,6 +797,30 @@ window.__ModuleLoader__.load({
648
797
  const [hover, setHover] = React.useState(null)
649
798
  const [drag, setDrag] = React.useState(null)
650
799
  const [dropping, setDropping] = React.useState(null)
800
+ const [look, setLook] = React.useState(() => readAppearance())
801
+ const [folders, setFolders] = React.useState({})
802
+ const [renaming, setRenaming] = React.useState(null)
803
+ const commitRename = async () => {
804
+ const draft = renaming
805
+ setRenaming(null)
806
+ if (!draft || !services.renameWorkspace) return
807
+ const title = String(draft.value || '').trim()
808
+ if (title === '' || title === draft.label) return
809
+ try {
810
+ await services.renameWorkspace(draft.workspaceId, title)
811
+ setNotice('Renamed to ' + title + '.')
812
+ } catch {
813
+ setNotice('Could not rename that workspace.')
814
+ }
815
+ }
816
+ const [savedOrder, setSavedOrder] = React.useState(() => readOrder())
817
+ const paintLook = (workspaceId, patch) => {
818
+ setLook((prev) => {
819
+ const next = withAppearance(prev, workspaceId, patch)
820
+ writeAppearance(next)
821
+ return next
822
+ })
823
+ }
651
824
  const filterRef = React.useRef(null)
652
825
 
653
826
  const workspaceState = useLive(services.workspacesLive)
@@ -662,7 +835,12 @@ window.__ModuleLoader__.load({
662
835
  )
663
836
  const allRows = workspaceRows(workspaceList, sessionList)
664
837
  const rows = filterTree(allRows, query, pinned)
665
- const walk = flattenRows(rows, expanded, query)
838
+ const folderOf = (workspace) => folderKey(workspace)
839
+ const visibleRows = rows.filter((group) => {
840
+ const key = folderOf(group.workspace)
841
+ return key === undefined || folders[key] !== true
842
+ })
843
+ const walk = flattenRows(visibleRows, expanded, query)
666
844
  const runtime = React.useMemo(() => menuRuntime(ctx, slots, standard), [ctx, slots, standard.wide])
667
845
  const RowBoundary = React.useMemo(() => rowBoundary(), [])
668
846
  const entries = React.useMemo(() => rowMenuEntries(slots), [slots, workspaceList.length, sessionList.length])
@@ -789,6 +967,56 @@ window.__ModuleLoader__.load({
789
967
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
790
968
  },
791
969
  }, 'Copy path'),
970
+ React.createElement('button', {
971
+ type: 'button',
972
+ 'data-polish-ws-rename': 'open',
973
+ style: WS_MENU_ITEM,
974
+ onClick: () => {
975
+ const workspace = menu.workspace
976
+ closeMenu()
977
+ setNotice('')
978
+ setRenaming({ workspaceId: workspace.workspaceId, label: workspaceLabel(workspace), value: workspaceLabel(workspace) })
979
+ },
980
+ }, 'Rename workspace…'),
981
+ React.createElement('button', {
982
+ type: 'button',
983
+ 'data-polish-order': 'remember',
984
+ style: WS_MENU_ITEM,
985
+ onClick: () => {
986
+ closeMenu()
987
+ const ids = workspaceList.map((workspace) => workspace.workspaceId)
988
+ writeOrder(ids)
989
+ setSavedOrder(ids)
990
+ setNotice('Order saved.')
991
+ },
992
+ }, 'Remember this order'),
993
+ savedOrder.length === 0
994
+ ? null
995
+ : React.createElement('button', {
996
+ type: 'button',
997
+ 'data-polish-order': 'restore',
998
+ style: WS_MENU_ITEM,
999
+ onClick: async () => {
1000
+ closeMenu()
1001
+ const ops = planReorder(workspaceList.map((workspace) => workspace.workspaceId), savedOrder)
1002
+ if (ops.length === 0) { setNotice('Already in the saved order.'); return }
1003
+ try {
1004
+ for (const op of ops) await services.reorderWorkspace(op.movingId, op.beforeId)
1005
+ setNotice('Order restored.')
1006
+ } catch {
1007
+ setNotice('Could not restore that order.')
1008
+ }
1009
+ },
1010
+ }, 'Restore saved order'),
1011
+ React.createElement('button', {
1012
+ type: 'button',
1013
+ 'data-polish-ws-appearance': 'open',
1014
+ style: WS_MENU_ITEM,
1015
+ onClick: () => {
1016
+ const workspace = menu.workspace
1017
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
1018
+ },
1019
+ }, 'Colour and weight…'),
792
1020
  React.createElement('button', {
793
1021
  type: 'button',
794
1022
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -805,6 +1033,44 @@ window.__ModuleLoader__.load({
805
1033
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
806
1034
  : null
807
1035
 
1036
+ const paintMenu = menu && menu.kind === 'paint'
1037
+ ? (() => {
1038
+ const workspace = menu.workspace
1039
+ const current = appearanceOf(look, workspace.workspaceId)
1040
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
1041
+ React.createElement('div', { style: WS_PAINT_ROW },
1042
+ TONE_ORDER.map((tone) => React.createElement('button', {
1043
+ key: tone,
1044
+ type: 'button',
1045
+ 'data-polish-tone': tone,
1046
+ 'aria-label': 'Colour ' + tone,
1047
+ title: tone,
1048
+ style: Object.assign({}, WS_SWATCH, {
1049
+ background: TONE_COLOR[tone],
1050
+ boxShadow: current.tone === tone
1051
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
1052
+ : undefined,
1053
+ }),
1054
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
1055
+ })),
1056
+ React.createElement('button', {
1057
+ key: 'none',
1058
+ type: 'button',
1059
+ 'data-polish-tone': 'none',
1060
+ 'aria-label': 'No colour',
1061
+ title: 'No colour',
1062
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
1063
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
1064
+ })),
1065
+ React.createElement('button', {
1066
+ type: 'button',
1067
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
1068
+ style: WS_MENU_ITEM,
1069
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
1070
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
1071
+ })()
1072
+ : null
1073
+
808
1074
  const sessionMenu = menu && menu.kind === 'session'
809
1075
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
810
1076
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -878,6 +1144,26 @@ window.__ModuleLoader__.load({
878
1144
  }))
879
1145
  }
880
1146
 
1147
+ if (renaming) {
1148
+ children.push(React.createElement('input', {
1149
+ key: 'rename',
1150
+ 'data-polish-ws-rename-input': 'true',
1151
+ autoFocus: true,
1152
+ value: renaming.value,
1153
+ placeholder: 'Workspace name (use Work/Name to file it)',
1154
+ 'aria-label': 'Workspace name',
1155
+ style: WS_FILTER,
1156
+ onChange: (event) => setRenaming(Object.assign({}, renaming, { value: event.target.value })),
1157
+ onKeyDown: (event) => {
1158
+ if (event.key === 'Escape') { event.stopPropagation(); setRenaming(null); return }
1159
+ if (event.key !== 'Enter') return
1160
+ event.preventDefault()
1161
+ void commitRename()
1162
+ },
1163
+ onBlur: () => { void commitRename() },
1164
+ }))
1165
+ }
1166
+
881
1167
  if (rows.length === 0) {
882
1168
  children.push(React.createElement('div', {
883
1169
  key: 'empty',
@@ -886,14 +1172,82 @@ window.__ModuleLoader__.load({
886
1172
  }
887
1173
 
888
1174
  let index = -1
1175
+ const announced = {}
1176
+
1177
+ const sessionRow = (workspace, row, groupIndex) => {
1178
+ const rowFocused = cursor === groupIndex
1179
+ const rowHovered = hover === 'session:' + row.id
1180
+ const lifted = dropping === 'session:' + row.id
1181
+ return React.createElement('div', {
1182
+ key: 'session:' + row.id,
1183
+ 'data-polish-ws-session': row.id,
1184
+ draggable: true,
1185
+ style: Object.assign({}, WS_ROW, {
1186
+ paddingLeft: '24px',
1187
+ opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
1188
+ background: lifted
1189
+ ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
1190
+ : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
1191
+ boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
1192
+ }),
1193
+ onClick: () => services.openSession(row.id),
1194
+ onContextMenu: (event) => openMenu(event, row, workspace),
1195
+ onMouseEnter: () => setHover('session:' + row.id),
1196
+ onMouseLeave: () => setHover(null),
1197
+ onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
1198
+ onDragOver: (event) => {
1199
+ if (!drag || drag.kind !== 'session' || drag.id === row.id) return
1200
+ event.preventDefault()
1201
+ setDropping('session:' + row.id)
1202
+ },
1203
+ onDragLeave: () => setDropping(null),
1204
+ onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
1205
+ },
1206
+ row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
1207
+ pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
1208
+ React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
1209
+ rowHovered || rowFocused
1210
+ ? React.createElement('button', {
1211
+ type: 'button',
1212
+ 'data-polish-ws-menu': 'session',
1213
+ 'aria-label': 'Session menu',
1214
+ style: WS_ROW_ACTION,
1215
+ onClick: (event) => openMenu(event, row, workspace),
1216
+ }, React.createElement(DotsGlyph))
1217
+ : null)
1218
+ }
1219
+
889
1220
  rows.forEach((group) => {
890
1221
  const workspace = group.workspace
891
1222
  const sessions = group.sessions
1223
+ const folder = folderOf(workspace)
1224
+ const folded = folder !== undefined && folders[folder] === true
1225
+
1226
+ if (folder !== undefined && !announced[folder]) {
1227
+ announced[folder] = true
1228
+ const inside = rows.filter((entry) => folderOf(entry.workspace) === folder)
1229
+ children.push(React.createElement('div', {
1230
+ key: 'folder:' + folder,
1231
+ 'data-polish-ws-folder': folder,
1232
+ role: 'group',
1233
+ 'aria-label': folder,
1234
+ title: folded ? 'Expand ' + folder : 'Collapse ' + folder,
1235
+ style: WS_FOLDER_ROW,
1236
+ onClick: () => setFolders((prev) => Object.assign({}, prev, { [folder]: prev[folder] !== true })),
1237
+ },
1238
+ React.createElement('span', { 'aria-hidden': 'true' }, folded ? '›' : '⌄'),
1239
+ React.createElement('span', { style: WS_TITLE }, folder),
1240
+ React.createElement('span', { style: WS_COUNT }, String(inside.reduce((sum, entry) => sum + entry.sessions.length, 0)))))
1241
+ }
1242
+
1243
+ if (folded) return
1244
+
892
1245
  index += 1
893
1246
  const open = expanded[workspace.workspaceId] !== false
894
1247
  const focused = cursor === index
895
1248
  const hovered = hover === 'workspace:' + workspace.workspaceId
896
1249
  const dropHere = dropping === 'workspace:' + workspace.workspaceId
1250
+ const looks = appearanceOf(look, workspace.workspaceId)
897
1251
 
898
1252
  children.push(React.createElement('div', {
899
1253
  key: 'workspace:' + workspace.workspaceId,
@@ -925,7 +1279,12 @@ window.__ModuleLoader__.load({
925
1279
  },
926
1280
  React.createElement(CaretGlyph, { open }),
927
1281
  React.createElement(FolderGlyph),
928
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1282
+ looks.tone
1283
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1284
+ : null,
1285
+ React.createElement('span', {
1286
+ style: Object.assign({}, WS_TITLE, { fontWeight: looks.strong ? 600 : undefined }),
1287
+ }, workspaceLabel(workspace)),
929
1288
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
930
1289
  hovered || focused
931
1290
  ? React.createElement('button', {
@@ -940,46 +1299,7 @@ window.__ModuleLoader__.load({
940
1299
  if (open) {
941
1300
  sessions.forEach((row) => {
942
1301
  index += 1
943
- const rowFocused = cursor === index
944
- const rowHovered = hover === 'session:' + row.id
945
- const lifted = dropping === 'session:' + row.id
946
- children.push(React.createElement('div', {
947
- key: 'session:' + row.id,
948
- 'data-polish-ws-session': row.id,
949
- draggable: true,
950
- style: Object.assign({}, WS_ROW, {
951
- paddingLeft: '24px',
952
- opacity: drag && drag.kind === 'session' && drag.id === row.id ? 0.5 : undefined,
953
- background: lifted
954
- ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.05))'
955
- : (rowFocused ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, 0.04))' : undefined),
956
- boxShadow: rowFocused ? 'inset 0 0 0 1px var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12))' : undefined,
957
- }),
958
- onClick: () => services.openSession(row.id),
959
- onContextMenu: (event) => openMenu(event, row, workspace),
960
- onMouseEnter: () => setHover('session:' + row.id),
961
- onMouseLeave: () => setHover(null),
962
- onDragStart: () => setDrag({ kind: 'session', id: row.id, workspaceId: workspace.workspaceId }),
963
- onDragOver: (event) => {
964
- if (!drag || drag.kind !== 'session' || drag.id === row.id) return
965
- event.preventDefault()
966
- setDropping('session:' + row.id)
967
- },
968
- onDragLeave: () => setDropping(null),
969
- onDrop: (event) => { event.preventDefault(); dropOnSession(workspace.workspaceId, row.id) },
970
- },
971
- row.running ? React.createElement('span', { style: WS_DOT, title: 'Running' }) : null,
972
- pinned.has(row.id) ? React.createElement('span', { style: WS_PIN, title: 'Pinned' }, '\u2605') : null,
973
- React.createElement('span', { style: WS_TITLE }, row.displayTitle || row.id),
974
- rowHovered || rowFocused
975
- ? React.createElement('button', {
976
- type: 'button',
977
- 'data-polish-ws-menu': 'session',
978
- 'aria-label': 'Session menu',
979
- style: WS_ROW_ACTION,
980
- onClick: (event) => openMenu(event, row, workspace),
981
- }, React.createElement(DotsGlyph))
982
- : null))
1302
+ children.push(sessionRow(workspace, row, index))
983
1303
  })
984
1304
  }
985
1305
  })
@@ -1007,9 +1327,81 @@ window.__ModuleLoader__.load({
1007
1327
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1008
1328
  : null,
1009
1329
  sessionMenu,
1330
+ paintMenu,
1010
1331
  workspaceMenu)
1011
1332
  }
1012
1333
 
1334
+ const WS_HERO = {
1335
+ display: 'flex',
1336
+ flexDirection: 'column',
1337
+ gap: '1px',
1338
+ minWidth: '220px',
1339
+ maxHeight: '320px',
1340
+ overflowY: 'auto',
1341
+ padding: '4px',
1342
+ }
1343
+
1344
+ const WS_HERO_TITLE = {
1345
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
1346
+ letterSpacing: '0.02em',
1347
+ textTransform: 'uppercase',
1348
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
1349
+ padding: '4px 6px 2px',
1350
+ }
1351
+
1352
+ /**
1353
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
1354
+ * reachable before a session exists — the Hero slot wants a list that calls
1355
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
1356
+ */
1357
+ function HeroWorkspacePicker(props) {
1358
+ const services = (props && props.services) || {}
1359
+ const workspaceState = useLive(services.workspacesLive)
1360
+ const look = React.useMemo(() => readAppearance(), [])
1361
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
1362
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
1363
+ // the list unconditionally left it floating over the Hero copy.
1364
+ if (!props || props.open !== true || list.length === 0) return null
1365
+ const anchor = props.anchorRef && props.anchorRef.current
1366
+ const spot = anchor && anchor.getBoundingClientRect
1367
+ ? (() => {
1368
+ const box = anchor.getBoundingClientRect()
1369
+ return {
1370
+ position: 'fixed',
1371
+ top: Math.round(box.bottom + 6) + 'px',
1372
+ left: Math.max(8, Math.round(box.left)) + 'px',
1373
+ zIndex: 1200,
1374
+ }
1375
+ })()
1376
+ : {}
1377
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
1378
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
1379
+ list.map((workspace) => {
1380
+ const looks = appearanceOf(look, workspace.workspaceId)
1381
+ return React.createElement('button', {
1382
+ key: workspace.workspaceId,
1383
+ type: 'button',
1384
+ 'data-polish-hero-row': workspace.workspaceId,
1385
+ style: Object.assign({}, WS_ROW, {
1386
+ fontWeight: looks.strong ? 600 : 500,
1387
+ background: 'transparent',
1388
+ border: 'none',
1389
+ textAlign: 'left',
1390
+ }),
1391
+ onClick: () => {
1392
+ if (props.onPick) props.onPick(workspace.workspaceId)
1393
+ if (props.onClose) props.onClose()
1394
+ },
1395
+ },
1396
+ React.createElement(FolderGlyph),
1397
+ looks.tone
1398
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1399
+ : null,
1400
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1401
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1402
+ })), document.body)
1403
+ }
1404
+
1013
1405
  /**
1014
1406
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
1015
1407
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -1017,6 +1409,7 @@ window.__ModuleLoader__.load({
1017
1409
  */
1018
1410
  function installWorkspaceSeat(ctx, configForms, extras = {}) {
1019
1411
  let dispose
1412
+ let disposeHero
1020
1413
  let unlisten
1021
1414
  const scope = () => {
1022
1415
  try {
@@ -1059,6 +1452,7 @@ window.__ModuleLoader__.load({
1059
1452
  reorderWorkspace: (movingId, beforeId) => seated.workspaces.insertBefore(movingId, beforeId),
1060
1453
  reorderSession: (workspaceId, movingId, beforeId) => seated.workspaces.insertSessionBefore(workspaceId, movingId, beforeId),
1061
1454
  removeWorkspace: (id) => seated.workspaces.delete(id),
1455
+ renameWorkspace: (id, title) => seated.workspaces.rename(id, title),
1062
1456
  openFolder: async (path) => {
1063
1457
  const answer = await seated.remote.session.openWorkspacePath({ path })
1064
1458
  if (answer && answer.ok === false) throw new Error('cannot open the folder')
@@ -1069,6 +1463,13 @@ window.__ModuleLoader__.load({
1069
1463
  priority: -1,
1070
1464
  inject: () => ({ services }),
1071
1465
  }, BetterWorkspace))
1466
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1467
+ // both live and die with the one switch.
1468
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1469
+ name: 'conversation.hero.workspace',
1470
+ priority: -1,
1471
+ inject: () => ({ services }),
1472
+ }, HeroWorkspacePicker))
1072
1473
  },
1073
1474
  )
1074
1475
  } catch (error) {
@@ -1081,6 +1482,12 @@ window.__ModuleLoader__.load({
1081
1482
  install()
1082
1483
  return
1083
1484
  }
1485
+ if (typeof disposeHero === 'function') {
1486
+ try {
1487
+ disposeHero()
1488
+ } catch {}
1489
+ }
1490
+ disposeHero = undefined
1084
1491
  if (typeof dispose === 'function') {
1085
1492
  try {
1086
1493
  dispose()
@@ -1098,6 +1505,7 @@ window.__ModuleLoader__.load({
1098
1505
  }
1099
1506
  return () => {
1100
1507
  if (typeof unlisten === 'function') unlisten()
1508
+ if (typeof disposeHero === 'function') disposeHero()
1101
1509
  if (typeof dispose === 'function') dispose()
1102
1510
  }
1103
1511
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.11.2",
3
+ "version": "0.13.1",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",