@aryacode/dsh-aryacode-polish 0.6.2 → 0.8.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/client/face.js CHANGED
@@ -678,6 +678,29 @@ const githubStore = (() => {
678
678
  }
679
679
  })()
680
680
 
681
+ /**
682
+ * The last GitHub status anyone read, shared by the dialog and the footer line.
683
+ *
684
+ * The footer used to re-read only on window focus, so signing out in the dialog
685
+ * left `GitHub · KonnectSuite` on screen while the CLI was already signed out (or
686
+ * signed in as another account). One store, written by every read, keeps them equal.
687
+ */
688
+ const githubStatusStore = (() => {
689
+ let value
690
+ const listeners = new Set()
691
+ return {
692
+ get: () => value,
693
+ subscribe(listener) {
694
+ listeners.add(listener)
695
+ return () => { listeners.delete(listener) }
696
+ },
697
+ set(next) {
698
+ value = next
699
+ for (const listener of listeners) listener()
700
+ },
701
+ }
702
+ })()
703
+
681
704
  /**
682
705
  * Post one action to the GitHub route.
683
706
  * @param action - `status`, `repos` or `clone`.
@@ -747,6 +770,20 @@ const GITHUB_LIST_STYLE = {
747
770
  borderTop: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
748
771
  }
749
772
 
773
+ /** A secondary button in the sign-in block, sized like the harness's own. */
774
+ const GITHUB_BUTTON_STYLE = {
775
+ display: 'inline-flex',
776
+ alignItems: 'center',
777
+ justifyContent: 'center',
778
+ padding: '4px 10px',
779
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.16))',
780
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
781
+ background: 'transparent',
782
+ color: 'inherit',
783
+ font: 'inherit',
784
+ cursor: 'pointer',
785
+ }
786
+
750
787
  const GITHUB_ROW_STYLE = {
751
788
  display: 'flex',
752
789
  alignItems: 'center',
@@ -762,8 +799,7 @@ const GITHUB_ROW_STYLE = {
762
799
  borderBottom: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.08))',
763
800
  }
764
801
 
765
- const GITHUB_INPUT_STYLE = {
766
- width: '100%',
802
+ const GITHUB_INPUT_STYLE = { width: '100%',
767
803
  boxSizing: 'border-box',
768
804
  padding: '6px 8px',
769
805
  font: 'inherit',
@@ -793,6 +829,33 @@ function GitHubPanel(props) {
793
829
  const [query, setQuery] = React.useState('')
794
830
  const [error, setError] = React.useState('')
795
831
  const [busy, setBusy] = React.useState('')
832
+ const [signIn, setSignIn] = React.useState({ state: 'idle', code: '', url: '', reason: '' })
833
+ const [confirmOut, setConfirmOut] = React.useState(false)
834
+ const [confirmAll, setConfirmAll] = React.useState(false)
835
+ const [notice, setNotice] = React.useState('')
836
+ const [copied, setCopied] = React.useState(false)
837
+
838
+ /** Put the one-time code on the clipboard, so it can be pasted in the browser. */
839
+ const copyCode = () => {
840
+ const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
841
+ if (clipboard === undefined || typeof clipboard.writeText !== 'function') return
842
+ Promise.resolve(clipboard.writeText(signIn.code))
843
+ .then(() => {
844
+ setCopied(true)
845
+ setTimeout(() => setCopied(false), 2000)
846
+ })
847
+ .catch(() => setCopied(false))
848
+ }
849
+
850
+ /** Re-read the session, and the repository list when it is signed in. */
851
+ const refresh = () => Promise.resolve()
852
+ .then(() => githubCall('status'))
853
+ .then(status => {
854
+ setAccount(status)
855
+ githubStatusStore.set(status)
856
+ if (status.signedIn !== true) return undefined
857
+ return githubCall('repos').then(list => setRepos(Array.isArray(list.repos) ? list.repos : []))
858
+ })
796
859
 
797
860
  React.useEffect(() => {
798
861
  if (!open || !enabled) return undefined
@@ -801,17 +864,9 @@ function GitHubPanel(props) {
801
864
  setError('')
802
865
  setBusy('')
803
866
  setQuery('')
804
- Promise.resolve()
805
- .then(() => githubCall('status'))
806
- .then(status => {
807
- if (!live) return undefined
808
- setAccount(status)
809
- if (status.signedIn !== true) return undefined
810
- return githubCall('repos').then(list => {
811
- if (!live) return
812
- setRepos(Array.isArray(list.repos) ? list.repos : [])
813
- })
814
- })
867
+ setConfirmOut(false)
868
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
869
+ refresh()
815
870
  .then(() => { if (live) setPhase('ready') })
816
871
  .catch(reason => {
817
872
  if (!live) return
@@ -821,6 +876,29 @@ function GitHubPanel(props) {
821
876
  return () => { live = false }
822
877
  }, [open, enabled])
823
878
 
879
+ // The device flow is a long poll: ask the Host what the CLI child reports.
880
+ // `starting` has to be polled too — the Host answers `starting` the moment the
881
+ // child is spawned and only reports the code a tick later, so polling on
882
+ // `pending` alone left the dialog asking for a code that had already arrived.
883
+ React.useEffect(() => {
884
+ if (!open || (signIn.state !== 'pending' && signIn.state !== 'starting')) return undefined
885
+ const timer = setInterval(() => {
886
+ Promise.resolve()
887
+ .then(() => githubCall('loginStatus'))
888
+ .then(answer => {
889
+ setSignIn({ state: answer.state, code: answer.code ?? '', url: answer.url ?? '', reason: answer.reason ?? '' })
890
+ if (answer.state === 'succeeded') {
891
+ setPhase('loading')
892
+ return refresh().then(() => setPhase('ready'))
893
+ }
894
+ if (answer.state === 'failed' || answer.state === 'cancelled') setError(answer.reason ?? 'Sign-in did not finish.')
895
+ return undefined
896
+ })
897
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
898
+ }, 2000)
899
+ return () => clearInterval(timer)
900
+ }, [open, signIn.state])
901
+
824
902
  React.useEffect(() => {
825
903
  if (!open) return undefined
826
904
  const onKey = event => { if (event.key === 'Escape') githubStore.set(false) }
@@ -865,6 +943,78 @@ function GitHubPanel(props) {
865
943
  })
866
944
  }
867
945
 
946
+ /**
947
+ * Start the CLI's device flow. The Host runs it, so this dialog only renders
948
+ * the code the CLI printed and the page it wants opened.
949
+ */
950
+ const beginSignIn = () => {
951
+ setError('')
952
+ setSignIn({ state: 'starting', code: '', url: '', reason: '' })
953
+ Promise.resolve()
954
+ .then(() => githubCall('login'))
955
+ .then(answer => setSignIn({
956
+ state: answer.state ?? 'failed',
957
+ code: answer.code ?? '',
958
+ url: answer.url ?? '',
959
+ reason: answer.reason ?? '',
960
+ }))
961
+ .catch(reason => {
962
+ setSignIn({ state: 'failed', code: '', url: '', reason: '' })
963
+ setError(reason instanceof Error ? reason.message : String(reason))
964
+ })
965
+ }
966
+
967
+ const cancelSignIn = () => {
968
+ Promise.resolve()
969
+ .then(() => githubCall('loginCancel'))
970
+ .then(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
971
+ .catch(() => setSignIn({ state: 'idle', code: '', url: '', reason: '' }))
972
+ }
973
+
974
+ /** Sign the CLI out, which is what every read in this feature depends on. */
975
+ const signOutNow = () => {
976
+ setConfirmOut(false)
977
+ setBusy('sign-out')
978
+ setError('')
979
+ setNotice('')
980
+ Promise.resolve()
981
+ .then(() => githubCall('logout'))
982
+ .then(answer => {
983
+ const remaining = typeof answer.remaining === 'string' ? answer.remaining : ''
984
+ setAccount(undefined)
985
+ setRepos([])
986
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
987
+ setNotice(remaining === ''
988
+ ? `Signed out of ${String(answer.login)}.`
989
+ : `Signed out of ${String(answer.login)}. The GitHub CLI is now signed in as ${remaining}.`)
990
+ return refresh()
991
+ })
992
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
993
+ .then(() => setBusy(''))
994
+ }
995
+
996
+ /** Sign every account in the CLI's keyring out, so no silent fallback is left. */
997
+ const signOutAllNow = () => {
998
+ setConfirmAll(false)
999
+ setBusy('sign-out-all')
1000
+ setError('')
1001
+ setNotice('')
1002
+ Promise.resolve()
1003
+ .then(() => githubCall('logoutAll'))
1004
+ .then(answer => {
1005
+ const logins = Array.isArray(answer.logins) ? answer.logins.map(String) : []
1006
+ setAccount(undefined)
1007
+ setRepos([])
1008
+ setSignIn({ state: 'idle', code: '', url: '', reason: '' })
1009
+ setNotice(logins.length === 0
1010
+ ? 'No GitHub account was signed in.'
1011
+ : `Signed out of ${logins.join(', ')}.`)
1012
+ return refresh()
1013
+ })
1014
+ .catch(reason => setError(reason instanceof Error ? reason.message : String(reason)))
1015
+ .then(() => setBusy(''))
1016
+ }
1017
+
868
1018
  const rows = visible.slice(0, 200).map(repo => React.createElement('button', {
869
1019
  key: repo.fullName,
870
1020
  type: 'button',
@@ -892,18 +1042,45 @@ function GitHubPanel(props) {
892
1042
  React.createElement('div', { style: GITHUB_CARD_STYLE, role: 'dialog', 'aria-label': 'Add a workspace from GitHub' },
893
1043
  React.createElement('div', { style: { ...PANEL_TITLE_STYLE, marginBottom: '4px' } },
894
1044
  React.createElement('span', null, 'Add a workspace from GitHub'),
895
- React.createElement('button', {
896
- type: 'button',
897
- onClick: () => githubStore.set(false),
898
- style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
899
- 'aria-label': 'Close',
900
- }, '×')),
1045
+ React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
1046
+ signedIn && (Array.isArray(account?.accounts) ? account.accounts.length : 0) > 1
1047
+ ? React.createElement('button', {
1048
+ type: 'button',
1049
+ 'data-polish-github': 'signoutall',
1050
+ disabled: busy === 'sign-out-all',
1051
+ onClick: () => { if (confirmAll) signOutAllNow(); else setConfirmAll(true) },
1052
+ title: 'Sign every GitHub account in the CLI out of this machine',
1053
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1054
+ }, busy === 'sign-out-all' ? 'Signing out…' : confirmAll ? 'Sign out all now?' : 'Sign out all')
1055
+ : null,
1056
+ signedIn
1057
+ ? React.createElement('button', {
1058
+ type: 'button',
1059
+ 'data-polish-github': 'signout',
1060
+ disabled: busy === 'sign-out',
1061
+ onClick: () => { if (confirmOut) signOutNow(); else setConfirmOut(true) },
1062
+ title: 'Sign the GitHub CLI out of this machine',
1063
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7, padding: 0 },
1064
+ }, busy === 'sign-out' ? 'Signing out…' : confirmOut ? 'Sign out now?' : 'Sign out')
1065
+ : null,
1066
+ React.createElement('button', {
1067
+ type: 'button',
1068
+ onClick: () => githubStore.set(false),
1069
+ style: { border: 'none', background: 'transparent', color: 'inherit', font: 'inherit', cursor: 'pointer', opacity: 0.7 },
1070
+ 'aria-label': 'Close',
1071
+ }, '×'))),
901
1072
  React.createElement('div', { style: { opacity: 0.75, minHeight: '18px' } },
902
1073
  phase === 'loading' && !signedIn ? 'Checking the GitHub CLI session…' : null,
903
1074
  signedIn ? `Signed in as ${String(account.login)} · cloning into ${root}` : null,
904
1075
  !signedIn && phase !== 'loading'
905
- ? 'The GitHub CLI is not signed in. Run `gh auth login` in a terminal, then reopen this dialog.'
1076
+ ? 'GitHub is not signed in on this machine. The sign-in below is the GitHub CLI device flow, so no token is ever typed here or stored by this plugin.'
906
1077
  : null),
1078
+ notice === ''
1079
+ ? null
1080
+ : React.createElement('div', {
1081
+ 'data-polish-github': 'notice',
1082
+ style: { marginTop: '6px', opacity: 0.85 },
1083
+ }, notice),
907
1084
  signedIn
908
1085
  ? React.createElement('input', {
909
1086
  style: { ...GITHUB_INPUT_STYLE, marginTop: '10px' },
@@ -917,6 +1094,48 @@ function GitHubPanel(props) {
917
1094
  error !== ''
918
1095
  ? React.createElement('div', { style: { marginTop: '10px', color: 'var(--dsw-alias-label-error, #b42318)' } }, error)
919
1096
  : null,
1097
+ !signedIn && phase !== 'loading'
1098
+ ? React.createElement('div', { style: { marginTop: '12px' } },
1099
+ signIn.state === 'pending' || signIn.state === 'starting'
1100
+ ? React.createElement('div', null,
1101
+ React.createElement('div', { style: { marginBottom: '8px' } },
1102
+ signIn.state === 'starting' ? 'Asking the GitHub CLI for a code…' : 'Approve this one-time code in your browser:'),
1103
+ signIn.code === ''
1104
+ ? null
1105
+ : React.createElement('div', {
1106
+ 'data-polish-github': 'code',
1107
+ style: {
1108
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1109
+ fontSize: '18px',
1110
+ letterSpacing: '2px',
1111
+ padding: '8px 10px',
1112
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1113
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
1114
+ },
1115
+ }, signIn.code),
1116
+ React.createElement('div', { style: { display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' } },
1117
+ React.createElement('button', { type: 'button', onClick: copyCode, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'copy' },
1118
+ copied ? 'Copied' : 'Copy code'),
1119
+ React.createElement('a', {
1120
+ href: signIn.url === '' ? 'https://github.com/login/device' : signIn.url,
1121
+ target: '_blank',
1122
+ rel: 'noreferrer',
1123
+ style: { ...GITHUB_BUTTON_STYLE, textDecoration: 'none', display: 'inline-flex', alignItems: 'center' },
1124
+ }, 'Open the device page'),
1125
+ React.createElement('button', { type: 'button', onClick: cancelSignIn, style: GITHUB_BUTTON_STYLE, 'data-polish-github': 'cancel' }, 'Cancel')))
1126
+ : React.createElement('div', null,
1127
+ React.createElement('button', {
1128
+ type: 'button',
1129
+ onClick: beginSignIn,
1130
+ // The attribute belongs on the element: inside `style` it is ignored,
1131
+ // which is exactly why the first verification could not find this button.
1132
+ 'data-polish-github': 'signin',
1133
+ style: GITHUB_BUTTON_STYLE,
1134
+ }, 'Sign in with GitHub'),
1135
+ signIn.state === 'failed' && signIn.reason !== ''
1136
+ ? React.createElement('div', { style: { marginTop: '8px', color: 'var(--dsw-alias-label-error, #b42318)' } }, signIn.reason)
1137
+ : null))
1138
+ : null,
920
1139
  signedIn
921
1140
  ? React.createElement('div', { style: GITHUB_LIST_STYLE, 'data-polish-github-list': String(visible.length) },
922
1141
  phase === 'loading' && visible.length === 0 ? React.createElement('div', { style: { padding: '10px 4px', opacity: 0.7 } }, 'Loading repositories…') : null,
@@ -961,6 +1180,9 @@ const FOOT_ROW_STYLE = {
961
1180
  */
962
1181
  function GithubStatus(props) {
963
1182
  const enabled = useSwitch(props.configForms, 'githubWorkspace', true)
1183
+ // The dialog publishes every status it reads, so a sign-out in the dialog lands
1184
+ // in this line immediately instead of waiting for the next window focus.
1185
+ const shared = React.useSyncExternalStore(githubStatusStore.subscribe, githubStatusStore.get)
964
1186
  const [status, setStatus] = React.useState(undefined)
965
1187
  React.useEffect(() => {
966
1188
  if (!enabled) return undefined
@@ -968,7 +1190,11 @@ function GithubStatus(props) {
968
1190
  const read = () => {
969
1191
  Promise.resolve()
970
1192
  .then(() => githubCall('status'))
971
- .then(next => { if (live) setStatus(next) })
1193
+ .then(next => {
1194
+ if (!live) return
1195
+ setStatus(next)
1196
+ githubStatusStore.set(next)
1197
+ })
972
1198
  .catch(() => { if (live) setStatus({ signedIn: false }) })
973
1199
  }
974
1200
  read()
@@ -980,15 +1206,16 @@ function GithubStatus(props) {
980
1206
  }
981
1207
  }, [enabled])
982
1208
  if (!enabled) return null
983
- const signedIn = status !== undefined && status.signedIn === true
984
- const label = status === undefined
1209
+ const current = shared === undefined ? status : shared
1210
+ const signedIn = current !== undefined && current.signedIn === true
1211
+ const label = current === undefined
985
1212
  ? 'GitHub · checking…'
986
- : signedIn ? `GitHub · ${String(status.login)}` : 'GitHub · signed out'
1213
+ : signedIn ? `GitHub · ${String(current.login)}` : 'GitHub · signed out'
987
1214
  return React.createElement('div', { style: { padding: '2px 8px 0', minWidth: 0 } },
988
1215
  React.createElement('button', {
989
1216
  type: 'button',
990
1217
  'data-polish-github': 'status',
991
- 'data-polish-github-state': signedIn ? 'in' : status === undefined ? 'checking' : 'out',
1218
+ 'data-polish-github-state': signedIn ? 'in' : current === undefined ? 'checking' : 'out',
992
1219
  title: signedIn
993
1220
  ? 'Add a workspace from GitHub'
994
1221
  : 'Sign in with the GitHub CLI (gh auth login), then add a repository as a workspace',
@@ -1250,6 +1477,349 @@ function WorkspaceSourceFlow(props) {
1250
1477
  document.body)
1251
1478
  }
1252
1479
 
1480
+ /**
1481
+ * Right-panel screenshot.
1482
+ *
1483
+ * The Shell offers no way to lift pixels out of itself: there is no capture
1484
+ * bridge on the desktop preload, `getDisplayMedia` has no source to offer, and
1485
+ * the Host has no route to the renderer's pixels. What a client face does have
1486
+ * is the panel's own DOM in the same document, so the picture is rasterised in
1487
+ * the page by the vendored rasteriser. docs/panel-screenshot.md records what
1488
+ * that means for the two iframes and for the window around the app.
1489
+ */
1490
+ const PANEL_TARGETS = [
1491
+ '[data-sidebar-right-session][data-sidebar-right-open] [data-dockkit-pane-active]',
1492
+ '[data-sidebar-right-session][data-sidebar-right-open]',
1493
+ '[data-dsh-panel][data-dsh-bottom-panel]',
1494
+ ]
1495
+
1496
+ /**
1497
+ * The element to photograph: the right panel's open tab, else the panel itself.
1498
+ * @returns the element, or undefined when no panel is open.
1499
+ */
1500
+ function panelTarget() {
1501
+ for (const selector of PANEL_TARGETS) {
1502
+ const node = document.querySelector(selector)
1503
+ if (node === null) continue
1504
+ const rect = node.getBoundingClientRect()
1505
+ if (rect.width < 48 || rect.height < 48) continue
1506
+ return node
1507
+ }
1508
+ return undefined
1509
+ }
1510
+
1511
+ /**
1512
+ * The first opaque background behind an element, so a transparent panel still
1513
+ * reads as a surface instead of turning into a black or checkered square.
1514
+ * @param node - element whose backdrop is wanted.
1515
+ * @returns a CSS colour.
1516
+ */
1517
+ function backgroundOf(node) {
1518
+ for (let current = node; current !== null && current !== undefined; current = current.parentElement) {
1519
+ const colour = window.getComputedStyle(current).backgroundColor
1520
+ if (colour !== '' && colour !== 'transparent' && colour !== 'rgba(0, 0, 0, 0)') return colour
1521
+ }
1522
+ return '#ffffff'
1523
+ }
1524
+
1525
+ /**
1526
+ * Rasterise one element to a PNG blob.
1527
+ * @param node - element to draw.
1528
+ * @returns the PNG.
1529
+ */
1530
+ async function capturePanel(node) {
1531
+ const rasterise = globalThis.html2canvas
1532
+ if (typeof rasterise !== 'function') throw new Error('the screenshot library did not load')
1533
+ const canvas = await rasterise(node, {
1534
+ backgroundColor: backgroundOf(node),
1535
+ // Capped at 2: text stays legible on a HiDPI screen without quadrupling the
1536
+ // file that gets uploaded with the message.
1537
+ scale: Math.min(2, Math.max(1.5, window.devicePixelRatio || 1)),
1538
+ logging: false,
1539
+ useCORS: true,
1540
+ })
1541
+ return await new Promise((resolve, reject) => {
1542
+ canvas.toBlob((value) => {
1543
+ if (value === null) reject(new Error('the panel could not be rasterised'))
1544
+ else resolve(value)
1545
+ }, 'image/png')
1546
+ })
1547
+ }
1548
+
1549
+ /**
1550
+ * Put a PNG on the clipboard.
1551
+ * @param blob - the PNG.
1552
+ * @returns whether the Shell allowed the write.
1553
+ */
1554
+ async function copyImage(blob) {
1555
+ if (typeof ClipboardItem !== 'function' || navigator.clipboard === undefined) return false
1556
+ try {
1557
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
1558
+ return true
1559
+ } catch {
1560
+ return false
1561
+ }
1562
+ }
1563
+
1564
+ /**
1565
+ * A file name for the attachment, matching the chat's own timestamped names.
1566
+ * @returns the name.
1567
+ */
1568
+ function shotName() {
1569
+ const now = new Date()
1570
+ const pad = (value) => String(value).padStart(2, '0')
1571
+ return 'right-panel-' + now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate())
1572
+ + '-' + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()) + '.png'
1573
+ }
1574
+
1575
+ const SHOT_GLYPH_STYLE = { width: '14px', height: '14px', flex: 'none', display: 'block' }
1576
+
1577
+ /**
1578
+ * Camera glyph, drawn here so the face depends on no icon package.
1579
+ * @returns the svg element.
1580
+ */
1581
+ function CameraGlyph() {
1582
+ return React.createElement('svg', {
1583
+ viewBox: '0 0 16 16',
1584
+ width: 14,
1585
+ height: 14,
1586
+ fill: 'none',
1587
+ stroke: 'currentColor',
1588
+ strokeWidth: 1.2,
1589
+ strokeLinecap: 'round',
1590
+ strokeLinejoin: 'round',
1591
+ 'aria-hidden': 'true',
1592
+ style: SHOT_GLYPH_STYLE,
1593
+ },
1594
+ React.createElement('path', { d: 'M2 5.5h2.2l1-1.5h5.6l1 1.5H14v7H2z' }),
1595
+ React.createElement('circle', { cx: 8, cy: 9, r: 2.2 }))
1596
+ }
1597
+
1598
+ /**
1599
+ * Where the camera button is drawn: the panel's own top-right corner, just
1600
+ * inside the frame and left of the Shell's expand/close controls.
1601
+ */
1602
+ const SHOT_ANCHOR_EDGE = 8
1603
+ const SHOT_ANCHOR_BUTTON = 28
1604
+ const SHOT_ANCHOR_CONTROLS = 96
1605
+
1606
+ /**
1607
+ * Where the open right panel's header ends, in viewport coordinates.
1608
+ * @returns the corner, or undefined when no panel is open.
1609
+ */
1610
+ function shotAnchor() {
1611
+ const panel = document.querySelector('[data-sidebar-right-session][data-sidebar-right-open]')
1612
+ if (panel === null) return undefined
1613
+ const rect = panel.getBoundingClientRect()
1614
+ if (rect.width < 120 || rect.height < 120) return undefined
1615
+ return {
1616
+ top: rect.top + SHOT_ANCHOR_EDGE,
1617
+ left: Math.max(rect.left + SHOT_ANCHOR_EDGE, rect.right - SHOT_ANCHOR_CONTROLS - SHOT_ANCHOR_BUTTON),
1618
+ }
1619
+ }
1620
+
1621
+ /**
1622
+ * The composer context the overlay button attaches through.
1623
+ *
1624
+ * The camera button lives in a root overlay so it can sit in the panel header,
1625
+ * but attaching an image needs the *session-scoped* composer props (`sessionId`
1626
+ * and `inputActions`), which only a session slot receives. The composer dock is
1627
+ * therefore kept as an invisible publisher and this store carries its context up
1628
+ * to the overlay; with no open Session the store is empty and the button hides.
1629
+ */
1630
+ const shotStore = (() => {
1631
+ const listeners = new Set()
1632
+ let value
1633
+ return {
1634
+ get: () => value,
1635
+ set: (next) => {
1636
+ value = next
1637
+ listeners.forEach((listener) => listener())
1638
+ },
1639
+ subscribe: (listener) => {
1640
+ listeners.add(listener)
1641
+ return () => listeners.delete(listener)
1642
+ },
1643
+ }
1644
+ })()
1645
+
1646
+ const SHOT_BUTTON_STYLE = {
1647
+ position: 'fixed',
1648
+ display: 'inline-flex',
1649
+ alignItems: 'center',
1650
+ justifyContent: 'center',
1651
+ width: SHOT_ANCHOR_BUTTON + 'px',
1652
+ height: SHOT_ANCHOR_BUTTON + 'px',
1653
+ padding: '0',
1654
+ border: 'none',
1655
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1656
+ background: 'transparent',
1657
+ color: 'var(--dsw-alias-label-tertiary, #61666b)',
1658
+ cursor: 'pointer',
1659
+ zIndex: 40,
1660
+ }
1661
+
1662
+ /**
1663
+ * Invisible composer-dock entry that publishes the session context the overlay
1664
+ * button needs, and nothing else.
1665
+ * @param props - session slot props (`sessionId`, `inputActions`) plus the
1666
+ * late `conversation` lookup.
1667
+ * @returns null.
1668
+ */
1669
+ function PanelShotContext(props) {
1670
+ React.useEffect(() => {
1671
+ shotStore.set({
1672
+ sessionId: props.sessionId,
1673
+ actions: props.inputActions,
1674
+ conversation: props.conversation,
1675
+ })
1676
+ return () => shotStore.set(undefined)
1677
+ }, [props.sessionId])
1678
+ return null
1679
+ }
1680
+
1681
+ /**
1682
+ * Camera button pinned to the right panel's top-right corner: photographs the
1683
+ * open tab, copies it, and attaches it to the message being written.
1684
+ * @param props - slot props plus the late `conversation` lookup.
1685
+ * @returns the button, or null while there is nothing to shoot or nowhere to
1686
+ * attach.
1687
+ */
1688
+ function PanelShotButton(props) {
1689
+ const on = useSwitch(props.configForms, 'panelScreenshot', true)
1690
+ const context = React.useSyncExternalStore(shotStore.subscribe, shotStore.get)
1691
+ const [anchor, setAnchor] = React.useState(() => shotAnchor())
1692
+ const [phase, setPhase] = React.useState('idle')
1693
+ const [note, setNote] = React.useState('')
1694
+ React.useEffect(() => {
1695
+ const measure = () => setAnchor(shotAnchor())
1696
+ measure()
1697
+ // The panel opens, closes, splits and resizes without any event this
1698
+ // component would otherwise hear, so watch the attribute and the geometry.
1699
+ const observer = new MutationObserver(measure)
1700
+ observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['data-sidebar-right-open', 'data-dockkit-pane-active'] })
1701
+ window.addEventListener('resize', measure)
1702
+ return () => {
1703
+ observer.disconnect()
1704
+ window.removeEventListener('resize', measure)
1705
+ }
1706
+ }, [])
1707
+ React.useEffect(() => {
1708
+ if (note === '') return undefined
1709
+ const timer = setTimeout(() => {
1710
+ setNote('')
1711
+ setPhase('idle')
1712
+ }, 4000)
1713
+ return () => clearTimeout(timer)
1714
+ }, [note])
1715
+ if (!on || context === undefined || anchor === undefined) return null
1716
+ const take = async () => {
1717
+ if (phase === 'busy') return
1718
+ const node = panelTarget()
1719
+ if (node === undefined) {
1720
+ setPhase('failed')
1721
+ setNote('open a panel on the right first')
1722
+ return
1723
+ }
1724
+ setPhase('busy')
1725
+ setNote('')
1726
+ try {
1727
+ const blob = await capturePanel(node)
1728
+ const copied = await copyImage(blob)
1729
+ const conversation = context.conversation()
1730
+ if (conversation === undefined) throw new Error('the chat is not available')
1731
+ // Two calls, because the Shell splits the job: `createDrafts` mints a
1732
+ // draft attachment and starts its upload (it is not on the public
1733
+ // `IConversation` type, but it is the only File -> id entry point and the
1734
+ // product reaches it the same way), and the composer's own `inputActions`
1735
+ // puts those ids in the message. Skipping the second call leaves the
1736
+ // image uploaded but attached to nothing.
1737
+ const drafts = conversation.createDrafts(
1738
+ context.sessionId,
1739
+ [new File([blob], shotName(), { type: 'image/png' })],
1740
+ true,
1741
+ )
1742
+ if (drafts === undefined || drafts.length === 0) throw new Error('the chat refused the image')
1743
+ const actions = context.actions
1744
+ if (actions === undefined || typeof actions.addAttachments !== 'function') {
1745
+ throw new Error('this Shell offers no composer to attach to')
1746
+ }
1747
+ if (!actions.addAttachments(drafts.map((draft) => draft.id))) {
1748
+ throw new Error('the composer refused the image')
1749
+ }
1750
+ setPhase('done')
1751
+ setNote(copied ? 'right panel copied and attached' : 'right panel attached, clipboard refused')
1752
+ } catch (error) {
1753
+ setPhase('failed')
1754
+ setNote(error instanceof Error ? error.message : String(error))
1755
+ console.warn('[aryacode-polish] panel screenshot failed:', error)
1756
+ }
1757
+ }
1758
+ const label = phase === 'done' ? '✓' : phase === 'failed' ? '!' : '●'
1759
+ return React.createElement(React.Fragment, null,
1760
+ React.createElement('span', {
1761
+ 'data-polish-shot-anchor': 'panel',
1762
+ style: { position: 'fixed', top: anchor.top + 'px', left: anchor.left + 'px', width: '0', height: '0' },
1763
+ }),
1764
+ React.createElement('button', {
1765
+ type: 'button',
1766
+ 'data-polish-shot': phase,
1767
+ title: note === '' ? 'Screenshot this panel into the chat (also copies it)' : note,
1768
+ 'aria-label': 'Screenshot this panel',
1769
+ disabled: phase === 'busy',
1770
+ onClick: () => { void take() },
1771
+ onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1772
+ onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
1773
+ style: {
1774
+ ...SHOT_BUTTON_STYLE,
1775
+ top: anchor.top + 'px',
1776
+ left: anchor.left + 'px',
1777
+ color: phase === 'failed'
1778
+ ? 'var(--dsw-alias-label-error, #b42318)'
1779
+ : 'var(--dsw-alias-label-tertiary, #61666b)',
1780
+ opacity: phase === 'busy' ? 0.5 : 1,
1781
+ cursor: phase === 'busy' ? 'progress' : 'pointer',
1782
+ fontSize: '11px',
1783
+ },
1784
+ },
1785
+ phase === 'done' || phase === 'failed'
1786
+ ? React.createElement('span', { 'aria-hidden': 'true' }, label)
1787
+ : React.createElement(CameraGlyph, null)),
1788
+ note === ''
1789
+ ? null
1790
+ : React.createElement('span', {
1791
+ 'data-polish-shot-note': phase,
1792
+ style: {
1793
+ position: 'fixed',
1794
+ top: anchor.top + SHOT_ANCHOR_BUTTON + 6 + 'px',
1795
+ left: Math.max(12, anchor.left - 140) + 'px',
1796
+ maxWidth: '260px',
1797
+ padding: '3px 8px',
1798
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1799
+ background: 'Canvas',
1800
+ boxShadow: 'var(--dsw-elevation-prominent, 0 3px 8px rgba(0,0,0,0.12))',
1801
+ color: phase === 'failed' ? 'var(--dsw-alias-label-error, #b42318)' : 'var(--dsw-alias-label-secondary, #61666b)',
1802
+ fontSize: '12px',
1803
+ lineHeight: '18px',
1804
+ whiteSpace: 'nowrap',
1805
+ zIndex: 41,
1806
+ },
1807
+ }, note))
1808
+ }
1809
+
1810
+ /**
1811
+ * Everything this bundle draws at the root: the GitHub dialog and the right
1812
+ * panel's camera button. `shell.overlay` is a single slot, so both live in one
1813
+ * registration instead of competing for it.
1814
+ * @param props - overlay slot props (`configForms`, `createWorkspace`).
1815
+ * @returns the fragment.
1816
+ */
1817
+ function ShellOverlay(props) {
1818
+ return React.createElement(React.Fragment, null,
1819
+ React.createElement(GitHubPanel, props),
1820
+ React.createElement(PanelShotButton, props))
1821
+ }
1822
+
1253
1823
  return {
1254
1824
  inject: ['slots'],
1255
1825
  /**
@@ -1275,6 +1845,25 @@ return {
1275
1845
  order: 2,
1276
1846
  inject: () => ({ configForms }),
1277
1847
  }, CostPill))
1848
+ // The camera button belongs to the panel header, which is a root surface, so
1849
+ // the composer dock only publishes the session context it needs. The chat
1850
+ // service is looked up late through ctx.get: it is a plain service, and a
1851
+ // Shell without it simply reports "the chat is not available".
1852
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1853
+ name: 'conversation.composer.dock',
1854
+ id: 'polish-shot',
1855
+ order: 3,
1856
+ inject: () => ({
1857
+ configForms,
1858
+ conversation: () => {
1859
+ try {
1860
+ return ctx.get('conversation')
1861
+ } catch {
1862
+ return undefined
1863
+ }
1864
+ },
1865
+ }),
1866
+ }, PanelShotContext))
1278
1867
  // The account Remote namespace is optional: a Shell with no account still gets
1279
1868
  // the Configure page and the cost pill, and only loses the balance line.
1280
1869
  ctx.inject(['remote', 'remote.account'], (accountCtx) => {
@@ -1327,7 +1916,7 @@ return {
1327
1916
  name: 'shell.overlay',
1328
1917
  id: 'polish-github',
1329
1918
  inject: () => ({ configForms, createWorkspace }),
1330
- }, GitHubPanel))
1919
+ }, ShellOverlay))
1331
1920
  // The footer keeps the sign-in state, not an add button: adding a
1332
1921
  // workspace belongs in the "+" menu, where the Shell says it does.
1333
1922
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({