@aryacode/dsh-aryacode-polish 0.7.3 → 0.8.2

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,42 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## [0.8.2] - 2026-10-28
5
+
6
+ ### Fixed
7
+
8
+ - The camera floated over the panel with metrics of its own, so it read as a
9
+ foreign control. It now mounts into the docking kit's chrome seat beside the
10
+ Shell's own presentation and collapse buttons, wearing that cluster's 28px box,
11
+ 15px glyph, hover state and tooltip, and it follows the strip through docked,
12
+ split and fullscreen layouts.
13
+
14
+ ## [0.8.1] - 2026-10-28
15
+
16
+ ### Changed
17
+
18
+ - The camera moved out of the composer row and next to the right panel's own
19
+ controls. The composer dock entry stays, invisibly, to publish the session
20
+ context the button needs.
21
+
22
+ ## [0.8.0] - 2026-10-28
23
+
24
+ ### Added
25
+
26
+ - **Right-panel screenshot.** A camera button in the composer row photographs the
27
+ right panel's open tab, copies the PNG to the clipboard, and attaches it to the
28
+ message being written — one click in place of a snip tool, for the panel rather
29
+ than the whole screen. The capture is a DOM rasterisation inside the page,
30
+ because the Shell exposes no capture bridge and no display-media source; the
31
+ browser tab and the HTML preview live in iframes and are the two surfaces it
32
+ cannot see. `docs/panel-screenshot.md` records the limits.
33
+
34
+ ### Changed
35
+
36
+ - `html2canvas` 1.4.1 is vendored at `vendor/html2canvas.min.js` and inlined into
37
+ `lib/client.js` by `pnpm build:client`: the Shell's module table answers `react`
38
+ and `react-dom` only, so a rasteriser has to travel inside the bundle.
39
+
3
40
  ## [0.7.3] - 2026-10-28
4
41
 
5
42
  ### Fixed
package/README.md CHANGED
@@ -7,7 +7,7 @@ installed into and patches nothing. It is one package, one Plugins row, and one
7
7
  switch per feature, so a refinement that turns out to be wrong for a machine can
8
8
  be turned off without uninstalling anything.
9
9
 
10
- Status: **five features shipped.** The version is the package's, and the card
10
+ Status: **six features shipped.** The version is the package's, and the card
11
11
  header already shows it.
12
12
 
13
13
  ## What it is
@@ -35,6 +35,7 @@ Configure page automatically.
35
35
  | **Open workspace folder** (`openWorkspaceFolder`) | On | An **Open workspace folder** row in a Session's "…" menu that opens the Session's workspace in Explorer/Finder. Renders nothing when the Session has no workspace or the Host cannot open a path. See [Open a workspace folder](docs/workspace-folder.md) for why the request had to land here instead of on the workspace row itself. |
36
36
  | **Add a workspace from GitHub** (`githubWorkspace`) | On | Adds the GitHub half of the workspace sources: the dialog lists the repositories your GitHub CLI session can clone, and cloning one creates the workspace. The token never enters the browser — it is read on the Host, used only as git's http auth header, and never stored. The sidebar foot carries the sign-in state (`GitHub · <login>`). See [GitHub workspaces](docs/github-workspaces.md). |
37
37
  | **Add a workspace from this PC or GitHub** (`workspaceSources`) | On | The Workspaces **+** button gets a source menu: **On this PC** (the Host's OS chooser, or a folder list when the composition has no native chooser) and **GitHub repository…**. Off, the **+** button goes back to the shipped folder picker. |
38
+ | **Right-panel screenshot** (`panelScreenshot`) | On | A camera button in the right panel's top-right corner photographs the open tab, copies the picture to the clipboard, and attaches it to the message being written. See [Right-panel screenshot](docs/panel-screenshot.md) for what it can and cannot capture. |
38
39
 
39
40
  ### How the cost is computed
40
41
 
package/client/face.js CHANGED
@@ -1477,6 +1477,364 @@ function WorkspaceSourceFlow(props) {
1477
1477
  document.body)
1478
1478
  }
1479
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
+
1576
+ /**
1577
+ * Camera glyph, drawn here so the face depends on no icon package.
1578
+ * @returns the svg element.
1579
+ */
1580
+ function CameraGlyph() {
1581
+ return React.createElement('svg', {
1582
+ viewBox: '0 0 16 16',
1583
+ width: 15,
1584
+ height: 15,
1585
+ fill: 'none',
1586
+ stroke: 'currentColor',
1587
+ strokeWidth: 1,
1588
+ strokeLinecap: 'round',
1589
+ strokeLinejoin: 'round',
1590
+ 'aria-hidden': 'true',
1591
+ },
1592
+ React.createElement('path', { d: 'M2.6 5.4h1.9l1-1.5h5l1 1.5h1.9a1 1 0 0 1 1 1v5.7a1 1 0 0 1-1 1H2.6a1 1 0 0 1-1-1V6.4a1 1 0 0 1 1-1Z' }),
1593
+ React.createElement('circle', { cx: 8, cy: 9.3, r: 2.5 }))
1594
+ }
1595
+
1596
+ /** Confirmation tick, drawn at the strip's own glyph size. */
1597
+ function CheckGlyph() {
1598
+ return React.createElement('svg', {
1599
+ viewBox: '0 0 16 16',
1600
+ width: 15,
1601
+ height: 15,
1602
+ fill: 'none',
1603
+ stroke: 'currentColor',
1604
+ strokeWidth: 1.4,
1605
+ strokeLinecap: 'round',
1606
+ strokeLinejoin: 'round',
1607
+ 'aria-hidden': 'true',
1608
+ },
1609
+ React.createElement('path', { d: 'M3.4 8.6l3 3 6.2-6.6' }))
1610
+ }
1611
+
1612
+ /** Refusal mark, drawn at the strip's own glyph size. */
1613
+ function BangGlyph() {
1614
+ return React.createElement('svg', {
1615
+ viewBox: '0 0 16 16',
1616
+ width: 15,
1617
+ height: 15,
1618
+ fill: 'none',
1619
+ stroke: 'currentColor',
1620
+ strokeWidth: 1.4,
1621
+ strokeLinecap: 'round',
1622
+ strokeLinejoin: 'round',
1623
+ 'aria-hidden': 'true',
1624
+ },
1625
+ React.createElement('path', { d: 'M8 3.6v5.2' }),
1626
+ React.createElement('path', { d: 'M8 12v.4' }))
1627
+ }
1628
+
1629
+ /**
1630
+ * The Shell has no header slot for the panel, so the camera rides the docking
1631
+ * kit's chrome seat: the element at the end of the top-right pane's tab strip
1632
+ * that already holds the presentation and collapse controls. Portalling into
1633
+ * that element, instead of floating over the panel at fixed coordinates, is what
1634
+ * makes the button share the cluster's gap, its vertical centring and whatever
1635
+ * geometry the strip has docked, fullscreened or split.
1636
+ */
1637
+ const SHOT_CHROME_SELECTOR = '[data-dockkit-strip-chrome]'
1638
+
1639
+ /**
1640
+ * Mirrors the Shell's own strip control: a 28px box with 6px padding around a
1641
+ * 15px glyph, secondary label colour, transparent until hovered.
1642
+ */
1643
+ const SHOT_BUTTON_STYLE = {
1644
+ display: 'inline-flex',
1645
+ flex: 'none',
1646
+ alignItems: 'center',
1647
+ justifyContent: 'center',
1648
+ width: '28px',
1649
+ height: '28px',
1650
+ padding: '6px',
1651
+ color: 'var(--dsw-alias-label-secondary, #61666b)',
1652
+ lineHeight: 1,
1653
+ background: 'transparent',
1654
+ border: 'none',
1655
+ borderRadius: 'var(--dsw-radius-sm, 6px)',
1656
+ cursor: 'pointer',
1657
+ // Ahead of the Shell's own two controls: the last thing you would reach for,
1658
+ // and never between them and the panel edge.
1659
+ order: -1,
1660
+ }
1661
+
1662
+ /**
1663
+ * The composer context the chrome button attaches through.
1664
+ *
1665
+ * The camera renders inside the panel strip, which is root chrome, but attaching
1666
+ * an image needs the *session-scoped* composer props (`sessionId` and
1667
+ * `inputActions`), which only a session slot receives. The composer dock is
1668
+ * therefore kept as an invisible publisher and this store carries its context up
1669
+ * to the chrome button; with no open Session the store is empty and the button
1670
+ * does not render.
1671
+ */
1672
+ const shotStore = (() => {
1673
+ const listeners = new Set()
1674
+ let value
1675
+ return {
1676
+ get: () => value,
1677
+ set: (next) => {
1678
+ value = next
1679
+ listeners.forEach((listener) => listener())
1680
+ },
1681
+ subscribe: (listener) => {
1682
+ listeners.add(listener)
1683
+ return () => listeners.delete(listener)
1684
+ },
1685
+ }
1686
+ })()
1687
+
1688
+ /**
1689
+ * Invisible composer-dock entry that publishes the session context the chrome
1690
+ * button needs, and nothing else.
1691
+ * @param props - session slot props (`sessionId`, `inputActions`) plus the
1692
+ * late `conversation` lookup.
1693
+ * @returns null.
1694
+ */
1695
+ function PanelShotContext(props) {
1696
+ React.useEffect(() => {
1697
+ shotStore.set({
1698
+ sessionId: props.sessionId,
1699
+ actions: props.inputActions,
1700
+ conversation: props.conversation,
1701
+ })
1702
+ return () => shotStore.set(undefined)
1703
+ }, [props.sessionId])
1704
+ return null
1705
+ }
1706
+
1707
+ /**
1708
+ * Camera button in the right panel's tab strip: photographs the open tab, copies
1709
+ * it, and attaches it to the message being written.
1710
+ * @param props - overlay slot props plus the late `conversation` lookup.
1711
+ * @returns the portalled button, or null while there is no panel or no Session.
1712
+ */
1713
+ function PanelShotButton(props) {
1714
+ const on = useSwitch(props.configForms, 'panelScreenshot', true)
1715
+ const context = React.useSyncExternalStore(shotStore.subscribe, shotStore.get)
1716
+ const [host, setHost] = React.useState(undefined)
1717
+ const [phase, setPhase] = React.useState('idle')
1718
+ const [note, setNote] = React.useState('')
1719
+ React.useEffect(() => {
1720
+ // The strip is created, destroyed and re-created as panes open, split and
1721
+ // close, so follow the document rather than measuring it once. Frames are
1722
+ // coalesced because React commits fire many mutations in a row.
1723
+ let frame = 0
1724
+ const find = () => {
1725
+ frame = 0
1726
+ setHost(document.querySelector(SHOT_CHROME_SELECTOR) ?? undefined)
1727
+ }
1728
+ const schedule = () => {
1729
+ if (frame === 0) frame = window.requestAnimationFrame(find)
1730
+ }
1731
+ find()
1732
+ const observer = new MutationObserver(schedule)
1733
+ observer.observe(document.body, { childList: true, subtree: true })
1734
+ return () => {
1735
+ if (frame !== 0) window.cancelAnimationFrame(frame)
1736
+ observer.disconnect()
1737
+ }
1738
+ }, [])
1739
+ React.useEffect(() => {
1740
+ if (note === '') return undefined
1741
+ const timer = setTimeout(() => {
1742
+ setNote('')
1743
+ setPhase('idle')
1744
+ }, 4000)
1745
+ return () => clearTimeout(timer)
1746
+ }, [note])
1747
+ if (!on || context === undefined || host === undefined) return null
1748
+ const take = async () => {
1749
+ if (phase === 'busy') return
1750
+ const node = panelTarget()
1751
+ if (node === undefined) {
1752
+ setPhase('failed')
1753
+ setNote('open a panel on the right first')
1754
+ return
1755
+ }
1756
+ setPhase('busy')
1757
+ setNote('')
1758
+ try {
1759
+ const blob = await capturePanel(node)
1760
+ const copied = await copyImage(blob)
1761
+ const conversation = context.conversation()
1762
+ if (conversation === undefined) throw new Error('the chat is not available')
1763
+ // Two calls, because the Shell splits the job: `createDrafts` mints a
1764
+ // draft attachment and starts its upload (it is not on the public
1765
+ // `IConversation` type, but it is the only File -> id entry point and the
1766
+ // product reaches it the same way), and the composer's own `inputActions`
1767
+ // puts those ids in the message. Skipping the second call leaves the
1768
+ // image uploaded but attached to nothing.
1769
+ const drafts = conversation.createDrafts(
1770
+ context.sessionId,
1771
+ [new File([blob], shotName(), { type: 'image/png' })],
1772
+ true,
1773
+ )
1774
+ if (drafts === undefined || drafts.length === 0) throw new Error('the chat refused the image')
1775
+ const actions = context.actions
1776
+ if (actions === undefined || typeof actions.addAttachments !== 'function') {
1777
+ throw new Error('this Shell offers no composer to attach to')
1778
+ }
1779
+ if (!actions.addAttachments(drafts.map((draft) => draft.id))) {
1780
+ throw new Error('the composer refused the image')
1781
+ }
1782
+ setPhase('done')
1783
+ setNote(copied ? 'Right panel copied and attached' : 'Right panel attached, clipboard refused')
1784
+ } catch (error) {
1785
+ setPhase('failed')
1786
+ setNote(error instanceof Error ? error.message : String(error))
1787
+ console.warn('[aryacode-polish] panel screenshot failed:', error)
1788
+ }
1789
+ }
1790
+ const tip = note !== ''
1791
+ ? note
1792
+ : phase === 'busy' ? 'Capturing the right panel…' : 'Screenshot this panel into the chat (also copies it)'
1793
+ const glyph = phase === 'done'
1794
+ ? React.createElement(CheckGlyph, null)
1795
+ : phase === 'failed'
1796
+ ? React.createElement(BangGlyph, null)
1797
+ : React.createElement(CameraGlyph, null)
1798
+ const button = React.createElement('button', {
1799
+ type: 'button',
1800
+ 'data-polish-shot': phase,
1801
+ 'aria-label': 'Screenshot this panel',
1802
+ disabled: phase === 'busy',
1803
+ onClick: () => { void take() },
1804
+ onMouseEnter: (event) => { event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,0.06))' },
1805
+ onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
1806
+ style: {
1807
+ ...SHOT_BUTTON_STYLE,
1808
+ color: phase === 'failed'
1809
+ ? 'var(--dsw-alias-label-error, #b42318)'
1810
+ : phase === 'done'
1811
+ ? 'var(--dsw-alias-label-primary, #111418)'
1812
+ : SHOT_BUTTON_STYLE.color,
1813
+ cursor: phase === 'busy' ? 'progress' : 'pointer',
1814
+ },
1815
+ }, glyph)
1816
+ const Tooltip = primitives === undefined ? undefined : primitives.Tooltip
1817
+ return ReactDOM.createPortal(
1818
+ Tooltip === undefined
1819
+ ? button
1820
+ : React.createElement(Tooltip, { label: tip, side: 'bottom', delayMs: 500 }, button),
1821
+ host,
1822
+ )
1823
+ }
1824
+
1825
+ /**
1826
+ * Everything this bundle draws at the root: the GitHub dialog and the right
1827
+ * panel's camera button. `shell.overlay` is a single slot, so both live in one
1828
+ * registration instead of competing for it.
1829
+ * @param props - overlay slot props (`configForms`, `createWorkspace`).
1830
+ * @returns the fragment.
1831
+ */
1832
+ function ShellOverlay(props) {
1833
+ return React.createElement(React.Fragment, null,
1834
+ React.createElement(GitHubPanel, props),
1835
+ React.createElement(PanelShotButton, props))
1836
+ }
1837
+
1480
1838
  return {
1481
1839
  inject: ['slots'],
1482
1840
  /**
@@ -1502,6 +1860,25 @@ return {
1502
1860
  order: 2,
1503
1861
  inject: () => ({ configForms }),
1504
1862
  }, CostPill))
1863
+ // The camera button belongs to the panel header, which is a root surface, so
1864
+ // the composer dock only publishes the session context it needs. The chat
1865
+ // service is looked up late through ctx.get: it is a plain service, and a
1866
+ // Shell without it simply reports "the chat is not available".
1867
+ ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
1868
+ name: 'conversation.composer.dock',
1869
+ id: 'polish-shot',
1870
+ order: 3,
1871
+ inject: () => ({
1872
+ configForms,
1873
+ conversation: () => {
1874
+ try {
1875
+ return ctx.get('conversation')
1876
+ } catch {
1877
+ return undefined
1878
+ }
1879
+ },
1880
+ }),
1881
+ }, PanelShotContext))
1505
1882
  // The account Remote namespace is optional: a Shell with no account still gets
1506
1883
  // the Configure page and the cost pill, and only loses the balance line.
1507
1884
  ctx.inject(['remote', 'remote.account'], (accountCtx) => {
@@ -1554,7 +1931,7 @@ return {
1554
1931
  name: 'shell.overlay',
1555
1932
  id: 'polish-github',
1556
1933
  inject: () => ({ configForms, createWorkspace }),
1557
- }, GitHubPanel))
1934
+ }, ShellOverlay))
1558
1935
  // The footer keeps the sign-in state, not an add button: adding a
1559
1936
  // workspace belongs in the "+" menu, where the Shell says it does.
1560
1937
  githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
@@ -30,6 +30,12 @@
30
30
  "label": "Add a workspace from this PC or GitHub",
31
31
  "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
32
32
  "defaultOn": true
33
+ },
34
+ {
35
+ "key": "panelScreenshot",
36
+ "label": "Right-panel screenshot",
37
+ "description": "Copy the open right panel into the chat as an image, and onto the clipboard.",
38
+ "defaultOn": true
33
39
  }
34
40
  ]
35
41
  }
@@ -0,0 +1,98 @@
1
+ # Right-panel screenshot
2
+
3
+ The right panel grows a camera button in its own top-right corner, beside the
4
+ Shell's split, expand and close controls. One click photographs the right
5
+ panel's **open tab**, puts the picture on the clipboard, and attaches it to the
6
+ message being written — the same place a pasted screenshot lands, so it can be
7
+ sent with the next prompt without touching another tool.
8
+
9
+ This page records how that works, and — more importantly — what it cannot do,
10
+ because the honest limits are the part that decides whether it replaces a snip
11
+ tool for a given job.
12
+
13
+ ## What it captures
14
+
15
+ The right panel is ordinary DOM in the same document as the chat, so the target
16
+ is found with an attribute selector rather than a private class name:
17
+
18
+ | target | selector |
19
+ |---|---|
20
+ | the open tab's body | `[data-sidebar-right-session][data-sidebar-right-open] [data-dockkit-pane-active]` |
21
+ | the panel itself (fallback) | `[data-sidebar-right-session][data-sidebar-right-open]` |
22
+ | the bottom panel (fallback) | `[data-dsh-panel][data-dsh-bottom-panel]` |
23
+
24
+ The first selector is what makes this work for both right sides: the Shell's own
25
+ sidebar and `dsh-better-sidebar` both render their tabs through the same dock
26
+ container, so the *active pane* is the current tab in either one. Targets are
27
+ skipped when they are smaller than 48 px in either direction, which keeps a
28
+ collapsed panel from producing a sliver.
29
+
30
+ ## How the picture is made
31
+
32
+ `html2canvas` (vendored at `vendor/html2canvas.min.js`, inlined into
33
+ `lib/client.js` by `scripts/build-client.mjs`) rasterises the element in the page
34
+ at `min(2, max(1.5, devicePixelRatio))`. The background is the first opaque
35
+ ancestor colour, so a transparent panel still reads as a surface.
36
+
37
+ This is the only route a plugin has. The Shell exposes:
38
+
39
+ - **no capture bridge** — the desktop preload exposes `__DSH_DIRECTORY_PICKER__`,
40
+ `__DSH_HOST_PATHS__`, `dshDesktopBoot`, `dshDesktop`, `dshPlatform`, and no
41
+ screenshot channel;
42
+ - **no working `getDisplayMedia`** — the API exists, but nothing installs a
43
+ display-media request handler on the app's session, so the call is refused
44
+ outright rather than offering a source;
45
+ - **no Host route to pixels** — the Host runs in a separate process and has no
46
+ access to the renderer.
47
+
48
+ Capture therefore happens in the renderer, from the DOM, which is also why it
49
+ works identically in the web build and in the app.
50
+
51
+ ## What it cannot capture
52
+
53
+ 1. **The browser tab and the HTML preview.** Both render inside an
54
+ `<iframe>`/`<webview>` whose document is not readable from the page, so a
55
+ rasteriser sees an empty rectangle there. Everything else — files, documents,
56
+ images, diffs, plans, subagent chats, plugin tabs — is plain DOM and captures
57
+ normally.
58
+ 2. **Anything outside the app window.** The button photographs a panel, not the
59
+ desktop, so a snip tool still wins for a region spanning two applications or a
60
+ native dialog.
61
+ 3. **Live pixels that refuse to be copied.** A cross-origin image without CORS
62
+ headers taints the canvas and would throw; the capture fails with a notice
63
+ instead of producing a blank picture. The Shell's own panels are same-origin,
64
+ so this only shows up with third-party content.
65
+
66
+ ## How the image reaches the message
67
+
68
+ Two calls, in this order, mirroring the composer's own intake:
69
+
70
+ ```js
71
+ // mints the draft attachment and starts its upload
72
+ const drafts = ctx.get('conversation').createDrafts(sessionId, [file], true)
73
+ // puts the minted ids in the composer, where the user can see and remove them
74
+ props.inputActions.addAttachments(drafts.map((draft) => draft.id))
75
+ ```
76
+
77
+ `createDrafts` is not part of the public `IConversation` interface, but it is the
78
+ only `File` → id entry point in the Shell, and the product itself reaches it the
79
+ same way. `addAttachments` is the public half: it is a standard prop on
80
+ session-scoped slots, which the composer row already is. The button itself is
81
+ root-level (it sits over the panel, not inside its DOM), so the composer dock
82
+ keeps an invisible entry that publishes the session context into a store; the
83
+ button reads that store and disappears when no Session is open. The bytes travel over
84
+ the Shell's own upload route, so a screenshot behaves exactly like a pasted
85
+ image, including for the model that receives it.
86
+
87
+ ## When it refuses
88
+
89
+ The button reports its own state instead of failing silently:
90
+
91
+ - no panel open → *open a panel on the right first*
92
+ - clipboard blocked → the image is still attached, with *right panel attached,
93
+ clipboard refused*
94
+ - anything else → the reason in the row, plus
95
+ `[aryacode-polish] panel screenshot failed:` in the console
96
+
97
+ The switch is in the row's Configure page like every other surface, and turning
98
+ it off removes the button without unloading anything else.
@@ -68,6 +68,12 @@ export const features = [
68
68
  defaultOn: true,
69
69
  mount: mountFolders,
70
70
  },
71
+ {
72
+ id: 'panelScreenshot',
73
+ label: 'Right-panel screenshot',
74
+ description: 'Copy the open right panel into the chat as an image, and onto the clipboard.',
75
+ defaultOn: true,
76
+ },
71
77
  ]
72
78
 
73
79
  /**