@aryacode/dsh-aryacode-polish 0.10.0 → 0.12.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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.1] - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - The Hero list now only appears when the workspace chip is open, and it paints a
8
+ card of its own instead of an unstyled list floating over the Hero copy.
9
+
10
+ ## [0.12.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - **Colour and weight per workspace.** Right-click a workspace → "Colour and
15
+ weight…" paints it one of six tones (slate, blue, green, amber, rose, violet) or
16
+ none, and can make its label bold. The choice is stored under
17
+ `aryacode-polish:workspace-appearance` in the app's own storage, so it survives a
18
+ restart, and a broken or missing store degrades to the plain tree instead of
19
+ throwing.
20
+ - **The same workspaces in the Hero picker.** The blank-session Hero now offers this
21
+ bundle's workspace list (tone dots, bold labels, session counts) instead of the
22
+ stock picker, through `conversation.hero.workspace` at priority -1 — same data,
23
+ same colours, one switch.
24
+
25
+ ## [0.11.2] - 2026-10-28
26
+
27
+ ### Verified
28
+
29
+ - Drag reorder now has live proof, not just unit tests: with two workspaces in the
30
+ Dev AryaCode desktop, dropping the second workspace on the first reordered them
31
+ (`workspaces.insertBefore`), and the new order survived a full app restart.
32
+
33
+ ### Fixed
34
+
35
+ - A session is never promised a move the Host cannot make. The remote workspace
36
+ namespace exposes `create`, `rename`, `delete`, `insertBefore`,
37
+ `insertSessionBefore`, `archiveSession`, `unarchiveSession`, `pinSession` and
38
+ `unpinSession` — and no `attachSession` or `detachSession` — while a workspace's own
39
+ `insertSessionBefore` only reorders the sessions already inside it (it rejects a
40
+ session it does not hold). So dropping a session on another workspace is refused
41
+ with a plain explanation instead of firing a call that fails, and the session menu
42
+ no longer offers "Move to <workspace>".
43
+
3
44
  ## [0.10.0] - 2026-10-28
4
45
 
5
46
  ### Added
@@ -382,6 +382,82 @@ export function moveBefore(list, movingId, beforeId) {
382
382
  return next
383
383
  }
384
384
 
385
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
+
387
+ export const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
388
+
389
+ export const TONE_COLOR = {
390
+ slate: '#8a8f98',
391
+ blue: '#4a72d0',
392
+ green: '#2f9e6b',
393
+ amber: '#c8892f',
394
+ rose: '#c2496a',
395
+ violet: '#7a5cc4',
396
+ }
397
+
398
+ export function appearanceOf(map, workspaceId) {
399
+ const entry = (map && map[workspaceId]) || {}
400
+ return {
401
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
402
+ strong: entry.strong === true,
403
+ }
404
+ }
405
+
406
+ export function withAppearance(map, workspaceId, patch) {
407
+ const next = Object.assign({}, map || {})
408
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
409
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
410
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
411
+ return next
412
+ }
413
+
414
+ export function readAppearance(storage) {
415
+ try {
416
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
417
+ if (!store) return {}
418
+ const raw = store.getItem(LOOK_KEY)
419
+ if (!raw) return {}
420
+ const parsed = JSON.parse(raw)
421
+ return parsed && typeof parsed === 'object' ? parsed : {}
422
+ } catch {
423
+ return {}
424
+ }
425
+ }
426
+
427
+ export function writeAppearance(map, storage) {
428
+ try {
429
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
430
+ if (!store) return
431
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
432
+ } catch {
433
+ /* a locked-down webview must not break the tree */
434
+ }
435
+ }
436
+
437
+ const WS_TONE = {
438
+ flex: 'none',
439
+ width: '6px',
440
+ height: '6px',
441
+ borderRadius: '50%',
442
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
443
+ }
444
+
445
+ const WS_SWATCH = {
446
+ width: '18px',
447
+ height: '18px',
448
+ borderRadius: '50%',
449
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
450
+ cursor: 'pointer',
451
+ padding: 0,
452
+ }
453
+
454
+ const WS_PAINT_ROW = {
455
+ display: 'flex',
456
+ alignItems: 'center',
457
+ gap: '6px',
458
+ padding: '6px 8px',
459
+ }
460
+
385
461
  const WS_FILTER = {
386
462
  margin: '2px 2px 6px',
387
463
  padding: '4px 8px',
@@ -447,6 +523,14 @@ export function BetterWorkspace(props) {
447
523
  const [hover, setHover] = React.useState(null)
448
524
  const [drag, setDrag] = React.useState(null)
449
525
  const [dropping, setDropping] = React.useState(null)
526
+ const [look, setLook] = React.useState(() => readAppearance())
527
+ const paintLook = (workspaceId, patch) => {
528
+ setLook((prev) => {
529
+ const next = withAppearance(prev, workspaceId, patch)
530
+ writeAppearance(next)
531
+ return next
532
+ })
533
+ }
450
534
  const filterRef = React.useRef(null)
451
535
 
452
536
  const workspaceState = useLive(services.workspacesLive)
@@ -533,7 +617,14 @@ export function BetterWorkspace(props) {
533
617
  setDrag(null)
534
618
  setDropping(null)
535
619
  if (!moving || moving.kind !== 'session' || moving.id === targetId) return
536
- if (moving.workspaceId !== workspaceId) { setNotice('A session stays in the workspace it belongs to.'); return }
620
+ if (moving.workspaceId !== workspaceId) {
621
+ // A session cannot change workspace from a plugin: the Host's remote
622
+ // namespace exposes create/rename/delete/insertBefore/insertSessionBefore
623
+ // and no attachSession or detachSession, and a workspace's own
624
+ // insertSessionBefore only reorders the sessions already in it.
625
+ setNotice('A session stays in its workspace; drag it within that workspace to reorder.')
626
+ return
627
+ }
537
628
  void reorderSession(workspaceId, moving.id, targetId)
538
629
  }
539
630
 
@@ -581,6 +672,15 @@ export function BetterWorkspace(props) {
581
672
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
582
673
  },
583
674
  }, 'Copy path'),
675
+ React.createElement('button', {
676
+ type: 'button',
677
+ 'data-polish-ws-appearance': 'open',
678
+ style: WS_MENU_ITEM,
679
+ onClick: () => {
680
+ const workspace = menu.workspace
681
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
682
+ },
683
+ }, 'Colour and weight…'),
584
684
  React.createElement('button', {
585
685
  type: 'button',
586
686
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -597,6 +697,44 @@ export function BetterWorkspace(props) {
597
697
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
598
698
  : null
599
699
 
700
+ const paintMenu = menu && menu.kind === 'paint'
701
+ ? (() => {
702
+ const workspace = menu.workspace
703
+ const current = appearanceOf(look, workspace.workspaceId)
704
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
705
+ React.createElement('div', { style: WS_PAINT_ROW },
706
+ TONE_ORDER.map((tone) => React.createElement('button', {
707
+ key: tone,
708
+ type: 'button',
709
+ 'data-polish-tone': tone,
710
+ 'aria-label': 'Colour ' + tone,
711
+ title: tone,
712
+ style: Object.assign({}, WS_SWATCH, {
713
+ background: TONE_COLOR[tone],
714
+ boxShadow: current.tone === tone
715
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
716
+ : undefined,
717
+ }),
718
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
719
+ })),
720
+ React.createElement('button', {
721
+ key: 'none',
722
+ type: 'button',
723
+ 'data-polish-tone': 'none',
724
+ 'aria-label': 'No colour',
725
+ title: 'No colour',
726
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
727
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
728
+ })),
729
+ React.createElement('button', {
730
+ type: 'button',
731
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
732
+ style: WS_MENU_ITEM,
733
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
734
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
735
+ })()
736
+ : null
737
+
600
738
  const sessionMenu = menu && menu.kind === 'session'
601
739
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
602
740
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -605,7 +743,8 @@ export function BetterWorkspace(props) {
605
743
  runtime,
606
744
  workspacesSnapshot: workspaceState,
607
745
  session: { sessionId: menu.row.id, displayTitle: menu.row.displayTitle, session: menu.row },
608
- }))))
746
+ }))),
747
+ null)
609
748
  : null
610
749
 
611
750
  const children = []
@@ -716,7 +855,16 @@ export function BetterWorkspace(props) {
716
855
  },
717
856
  React.createElement(CaretGlyph, { open }),
718
857
  React.createElement(FolderGlyph),
719
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
858
+ look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
859
+ ? React.createElement('span', {
860
+ style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
861
+ })
862
+ : null,
863
+ React.createElement('span', {
864
+ style: Object.assign({}, WS_TITLE, {
865
+ fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
866
+ }),
867
+ }, workspaceLabel(workspace)),
720
868
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
721
869
  hovered || focused
722
870
  ? React.createElement('button', {
@@ -798,9 +946,81 @@ export function BetterWorkspace(props) {
798
946
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
799
947
  : null,
800
948
  sessionMenu,
949
+ paintMenu,
801
950
  workspaceMenu)
802
951
  }
803
952
 
953
+ const WS_HERO = {
954
+ display: 'flex',
955
+ flexDirection: 'column',
956
+ gap: '1px',
957
+ minWidth: '220px',
958
+ maxHeight: '320px',
959
+ overflowY: 'auto',
960
+ padding: '4px',
961
+ }
962
+
963
+ const WS_HERO_TITLE = {
964
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
965
+ letterSpacing: '0.02em',
966
+ textTransform: 'uppercase',
967
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
968
+ padding: '4px 6px 2px',
969
+ }
970
+
971
+ /**
972
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
973
+ * reachable before a session exists — the Hero slot wants a list that calls
974
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
975
+ */
976
+ export function HeroWorkspacePicker(props) {
977
+ const services = (props && props.services) || {}
978
+ const workspaceState = useLive(services.workspacesLive)
979
+ const look = React.useMemo(() => readAppearance(), [])
980
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
981
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
982
+ // the list unconditionally left it floating over the Hero copy.
983
+ if (!props || props.open !== true || list.length === 0) return null
984
+ const anchor = props.anchorRef && props.anchorRef.current
985
+ const spot = anchor && anchor.getBoundingClientRect
986
+ ? (() => {
987
+ const box = anchor.getBoundingClientRect()
988
+ return {
989
+ position: 'fixed',
990
+ top: Math.round(box.bottom + 6) + 'px',
991
+ left: Math.max(8, Math.round(box.left)) + 'px',
992
+ zIndex: 1200,
993
+ }
994
+ })()
995
+ : {}
996
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
997
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
998
+ list.map((workspace) => {
999
+ const looks = appearanceOf(look, workspace.workspaceId)
1000
+ return React.createElement('button', {
1001
+ key: workspace.workspaceId,
1002
+ type: 'button',
1003
+ 'data-polish-hero-row': workspace.workspaceId,
1004
+ style: Object.assign({}, WS_ROW, {
1005
+ fontWeight: looks.strong ? 600 : 500,
1006
+ background: 'transparent',
1007
+ border: 'none',
1008
+ textAlign: 'left',
1009
+ }),
1010
+ onClick: () => {
1011
+ if (props.onPick) props.onPick(workspace.workspaceId)
1012
+ if (props.onClose) props.onClose()
1013
+ },
1014
+ },
1015
+ React.createElement(FolderGlyph),
1016
+ looks.tone
1017
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1018
+ : null,
1019
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1020
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1021
+ })), document.body)
1022
+ }
1023
+
804
1024
  /**
805
1025
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
806
1026
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -808,6 +1028,7 @@ export function BetterWorkspace(props) {
808
1028
  */
809
1029
  export function installWorkspaceSeat(ctx, configForms, extras = {}) {
810
1030
  let dispose
1031
+ let disposeHero
811
1032
  let unlisten
812
1033
  const scope = () => {
813
1034
  try {
@@ -860,6 +1081,13 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
860
1081
  priority: -1,
861
1082
  inject: () => ({ services }),
862
1083
  }, BetterWorkspace))
1084
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1085
+ // both live and die with the one switch.
1086
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1087
+ name: 'conversation.hero.workspace',
1088
+ priority: -1,
1089
+ inject: () => ({ services }),
1090
+ }, HeroWorkspacePicker))
863
1091
  },
864
1092
  )
865
1093
  } catch (error) {
@@ -872,6 +1100,12 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
872
1100
  install()
873
1101
  return
874
1102
  }
1103
+ if (typeof disposeHero === 'function') {
1104
+ try {
1105
+ disposeHero()
1106
+ } catch {}
1107
+ }
1108
+ disposeHero = undefined
875
1109
  if (typeof dispose === 'function') {
876
1110
  try {
877
1111
  dispose()
@@ -889,6 +1123,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
889
1123
  }
890
1124
  return () => {
891
1125
  if (typeof unlisten === 'function') unlisten()
1126
+ if (typeof disposeHero === 'function') disposeHero()
892
1127
  if (typeof dispose === 'function') dispose()
893
1128
  }
894
1129
  }
@@ -70,3 +70,47 @@ are M2/M3; the seat is already the right place for them.
70
70
 
71
71
  Still owed: "/"-in-title grouping, per-row appearance, workspace presets, and the same
72
72
  tree in the Hero picker.
73
+
74
+ ## Why a session cannot change workspace
75
+
76
+ Drag reorder works inside a workspace and between workspaces, but a *session* cannot
77
+ be dragged into a different workspace, and that is the Host's shape rather than an
78
+ omission here:
79
+
80
+ - `packages/api/workspace-controller/src/index.ts` exposes these remotes:
81
+ `create`, `initializeDefault`, `rename`, `delete`, `insertBefore`,
82
+ `insertSessionBefore`, `archiveSession`, `unarchiveSession`, `pinSession`,
83
+ `unpinSession` — and nothing that attaches or detaches a session.
84
+ - `packages/api/workspace-controller/src/commands.ts:132` resolves the target workspace
85
+ and calls its own `insertSessionBefore(sessionId, beforeSessionId)`
86
+ (`packages/workspace/workspace/src/entity.ts:152`), which reorders that workspace's
87
+ `sessionIds`; `packages/workspace/workspace/tests/workspace.spec.ts:715` pins that a
88
+ session the workspace does not hold is rejected.
89
+ - The entity does have `attachSession`/`detachSession`
90
+ (`packages/workspace/workspace/src/types.ts:134` region), but no remote surfaces them.
91
+
92
+ So the tree refuses with "A session stays in its workspace; drag it within that
93
+ workspace to reorder." rather than attempting a call that throws. If the Host ever
94
+ exposes attach/detach, the gesture becomes a one-line change here — the drag payload
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()`.
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.10.0"
56
+ const CLIENT_VERSION = "0.12.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,82 @@ window.__ModuleLoader__.load({
583
583
  return next
584
584
  }
585
585
 
586
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
587
+
588
+ const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
589
+
590
+ const TONE_COLOR = {
591
+ slate: '#8a8f98',
592
+ blue: '#4a72d0',
593
+ green: '#2f9e6b',
594
+ amber: '#c8892f',
595
+ rose: '#c2496a',
596
+ violet: '#7a5cc4',
597
+ }
598
+
599
+ function appearanceOf(map, workspaceId) {
600
+ const entry = (map && map[workspaceId]) || {}
601
+ return {
602
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
603
+ strong: entry.strong === true,
604
+ }
605
+ }
606
+
607
+ function withAppearance(map, workspaceId, patch) {
608
+ const next = Object.assign({}, map || {})
609
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
610
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
611
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
612
+ return next
613
+ }
614
+
615
+ function readAppearance(storage) {
616
+ try {
617
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
618
+ if (!store) return {}
619
+ const raw = store.getItem(LOOK_KEY)
620
+ if (!raw) return {}
621
+ const parsed = JSON.parse(raw)
622
+ return parsed && typeof parsed === 'object' ? parsed : {}
623
+ } catch {
624
+ return {}
625
+ }
626
+ }
627
+
628
+ function writeAppearance(map, storage) {
629
+ try {
630
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
631
+ if (!store) return
632
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
633
+ } catch {
634
+ /* a locked-down webview must not break the tree */
635
+ }
636
+ }
637
+
638
+ const WS_TONE = {
639
+ flex: 'none',
640
+ width: '6px',
641
+ height: '6px',
642
+ borderRadius: '50%',
643
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
644
+ }
645
+
646
+ const WS_SWATCH = {
647
+ width: '18px',
648
+ height: '18px',
649
+ borderRadius: '50%',
650
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
651
+ cursor: 'pointer',
652
+ padding: 0,
653
+ }
654
+
655
+ const WS_PAINT_ROW = {
656
+ display: 'flex',
657
+ alignItems: 'center',
658
+ gap: '6px',
659
+ padding: '6px 8px',
660
+ }
661
+
586
662
  const WS_FILTER = {
587
663
  margin: '2px 2px 6px',
588
664
  padding: '4px 8px',
@@ -648,6 +724,14 @@ window.__ModuleLoader__.load({
648
724
  const [hover, setHover] = React.useState(null)
649
725
  const [drag, setDrag] = React.useState(null)
650
726
  const [dropping, setDropping] = React.useState(null)
727
+ const [look, setLook] = React.useState(() => readAppearance())
728
+ const paintLook = (workspaceId, patch) => {
729
+ setLook((prev) => {
730
+ const next = withAppearance(prev, workspaceId, patch)
731
+ writeAppearance(next)
732
+ return next
733
+ })
734
+ }
651
735
  const filterRef = React.useRef(null)
652
736
 
653
737
  const workspaceState = useLive(services.workspacesLive)
@@ -734,7 +818,14 @@ window.__ModuleLoader__.load({
734
818
  setDrag(null)
735
819
  setDropping(null)
736
820
  if (!moving || moving.kind !== 'session' || moving.id === targetId) return
737
- if (moving.workspaceId !== workspaceId) { setNotice('A session stays in the workspace it belongs to.'); return }
821
+ if (moving.workspaceId !== workspaceId) {
822
+ // A session cannot change workspace from a plugin: the Host's remote
823
+ // namespace exposes create/rename/delete/insertBefore/insertSessionBefore
824
+ // and no attachSession or detachSession, and a workspace's own
825
+ // insertSessionBefore only reorders the sessions already in it.
826
+ setNotice('A session stays in its workspace; drag it within that workspace to reorder.')
827
+ return
828
+ }
738
829
  void reorderSession(workspaceId, moving.id, targetId)
739
830
  }
740
831
 
@@ -782,6 +873,15 @@ window.__ModuleLoader__.load({
782
873
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
783
874
  },
784
875
  }, 'Copy path'),
876
+ React.createElement('button', {
877
+ type: 'button',
878
+ 'data-polish-ws-appearance': 'open',
879
+ style: WS_MENU_ITEM,
880
+ onClick: () => {
881
+ const workspace = menu.workspace
882
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
883
+ },
884
+ }, 'Colour and weight…'),
785
885
  React.createElement('button', {
786
886
  type: 'button',
787
887
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -798,6 +898,44 @@ window.__ModuleLoader__.load({
798
898
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
799
899
  : null
800
900
 
901
+ const paintMenu = menu && menu.kind === 'paint'
902
+ ? (() => {
903
+ const workspace = menu.workspace
904
+ const current = appearanceOf(look, workspace.workspaceId)
905
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
906
+ React.createElement('div', { style: WS_PAINT_ROW },
907
+ TONE_ORDER.map((tone) => React.createElement('button', {
908
+ key: tone,
909
+ type: 'button',
910
+ 'data-polish-tone': tone,
911
+ 'aria-label': 'Colour ' + tone,
912
+ title: tone,
913
+ style: Object.assign({}, WS_SWATCH, {
914
+ background: TONE_COLOR[tone],
915
+ boxShadow: current.tone === tone
916
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
917
+ : undefined,
918
+ }),
919
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
920
+ })),
921
+ React.createElement('button', {
922
+ key: 'none',
923
+ type: 'button',
924
+ 'data-polish-tone': 'none',
925
+ 'aria-label': 'No colour',
926
+ title: 'No colour',
927
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
928
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
929
+ })),
930
+ React.createElement('button', {
931
+ type: 'button',
932
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
933
+ style: WS_MENU_ITEM,
934
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
935
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
936
+ })()
937
+ : null
938
+
801
939
  const sessionMenu = menu && menu.kind === 'session'
802
940
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
803
941
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -806,7 +944,8 @@ window.__ModuleLoader__.load({
806
944
  runtime,
807
945
  workspacesSnapshot: workspaceState,
808
946
  session: { sessionId: menu.row.id, displayTitle: menu.row.displayTitle, session: menu.row },
809
- }))))
947
+ }))),
948
+ null)
810
949
  : null
811
950
 
812
951
  const children = []
@@ -917,7 +1056,16 @@ window.__ModuleLoader__.load({
917
1056
  },
918
1057
  React.createElement(CaretGlyph, { open }),
919
1058
  React.createElement(FolderGlyph),
920
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1059
+ look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
1060
+ ? React.createElement('span', {
1061
+ style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
1062
+ })
1063
+ : null,
1064
+ React.createElement('span', {
1065
+ style: Object.assign({}, WS_TITLE, {
1066
+ fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
1067
+ }),
1068
+ }, workspaceLabel(workspace)),
921
1069
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
922
1070
  hovered || focused
923
1071
  ? React.createElement('button', {
@@ -999,9 +1147,81 @@ window.__ModuleLoader__.load({
999
1147
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1000
1148
  : null,
1001
1149
  sessionMenu,
1150
+ paintMenu,
1002
1151
  workspaceMenu)
1003
1152
  }
1004
1153
 
1154
+ const WS_HERO = {
1155
+ display: 'flex',
1156
+ flexDirection: 'column',
1157
+ gap: '1px',
1158
+ minWidth: '220px',
1159
+ maxHeight: '320px',
1160
+ overflowY: 'auto',
1161
+ padding: '4px',
1162
+ }
1163
+
1164
+ const WS_HERO_TITLE = {
1165
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
1166
+ letterSpacing: '0.02em',
1167
+ textTransform: 'uppercase',
1168
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
1169
+ padding: '4px 6px 2px',
1170
+ }
1171
+
1172
+ /**
1173
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
1174
+ * reachable before a session exists — the Hero slot wants a list that calls
1175
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
1176
+ */
1177
+ function HeroWorkspacePicker(props) {
1178
+ const services = (props && props.services) || {}
1179
+ const workspaceState = useLive(services.workspacesLive)
1180
+ const look = React.useMemo(() => readAppearance(), [])
1181
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
1182
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
1183
+ // the list unconditionally left it floating over the Hero copy.
1184
+ if (!props || props.open !== true || list.length === 0) return null
1185
+ const anchor = props.anchorRef && props.anchorRef.current
1186
+ const spot = anchor && anchor.getBoundingClientRect
1187
+ ? (() => {
1188
+ const box = anchor.getBoundingClientRect()
1189
+ return {
1190
+ position: 'fixed',
1191
+ top: Math.round(box.bottom + 6) + 'px',
1192
+ left: Math.max(8, Math.round(box.left)) + 'px',
1193
+ zIndex: 1200,
1194
+ }
1195
+ })()
1196
+ : {}
1197
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
1198
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
1199
+ list.map((workspace) => {
1200
+ const looks = appearanceOf(look, workspace.workspaceId)
1201
+ return React.createElement('button', {
1202
+ key: workspace.workspaceId,
1203
+ type: 'button',
1204
+ 'data-polish-hero-row': workspace.workspaceId,
1205
+ style: Object.assign({}, WS_ROW, {
1206
+ fontWeight: looks.strong ? 600 : 500,
1207
+ background: 'transparent',
1208
+ border: 'none',
1209
+ textAlign: 'left',
1210
+ }),
1211
+ onClick: () => {
1212
+ if (props.onPick) props.onPick(workspace.workspaceId)
1213
+ if (props.onClose) props.onClose()
1214
+ },
1215
+ },
1216
+ React.createElement(FolderGlyph),
1217
+ looks.tone
1218
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1219
+ : null,
1220
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1221
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1222
+ })), document.body)
1223
+ }
1224
+
1005
1225
  /**
1006
1226
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
1007
1227
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -1009,6 +1229,7 @@ window.__ModuleLoader__.load({
1009
1229
  */
1010
1230
  function installWorkspaceSeat(ctx, configForms, extras = {}) {
1011
1231
  let dispose
1232
+ let disposeHero
1012
1233
  let unlisten
1013
1234
  const scope = () => {
1014
1235
  try {
@@ -1061,6 +1282,13 @@ window.__ModuleLoader__.load({
1061
1282
  priority: -1,
1062
1283
  inject: () => ({ services }),
1063
1284
  }, BetterWorkspace))
1285
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1286
+ // both live and die with the one switch.
1287
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1288
+ name: 'conversation.hero.workspace',
1289
+ priority: -1,
1290
+ inject: () => ({ services }),
1291
+ }, HeroWorkspacePicker))
1064
1292
  },
1065
1293
  )
1066
1294
  } catch (error) {
@@ -1073,6 +1301,12 @@ window.__ModuleLoader__.load({
1073
1301
  install()
1074
1302
  return
1075
1303
  }
1304
+ if (typeof disposeHero === 'function') {
1305
+ try {
1306
+ disposeHero()
1307
+ } catch {}
1308
+ }
1309
+ disposeHero = undefined
1076
1310
  if (typeof dispose === 'function') {
1077
1311
  try {
1078
1312
  dispose()
@@ -1090,6 +1324,7 @@ window.__ModuleLoader__.load({
1090
1324
  }
1091
1325
  return () => {
1092
1326
  if (typeof unlisten === 'function') unlisten()
1327
+ if (typeof disposeHero === 'function') disposeHero()
1093
1328
  if (typeof dispose === 'function') dispose()
1094
1329
  }
1095
1330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",