@aryacode/dsh-aryacode-polish 0.7.3 → 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/CHANGELOG.md +26 -0
- package/README.md +2 -1
- package/client/face.js +363 -1
- package/client/switches.json +6 -0
- package/docs/panel-screenshot.md +98 -0
- package/entries/registry.js +6 -0
- package/lib/client.js +393 -2
- package/package.json +4 -2
- package/vendor/html2canvas.min.js +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.1] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- The camera moved out of the composer row and into the open right panel's own
|
|
8
|
+
top-right corner, where the panel is what you are looking at. The composer
|
|
9
|
+
dock entry stays, invisibly, to publish the session context the button needs.
|
|
10
|
+
|
|
11
|
+
## [0.8.0] - 2026-10-28
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Right-panel screenshot.** A camera button in the composer row photographs the
|
|
16
|
+
right panel's open tab, copies the PNG to the clipboard, and attaches it to the
|
|
17
|
+
message being written — one click in place of a snip tool, for the panel rather
|
|
18
|
+
than the whole screen. The capture is a DOM rasterisation inside the page,
|
|
19
|
+
because the Shell exposes no capture bridge and no display-media source; the
|
|
20
|
+
browser tab and the HTML preview live in iframes and are the two surfaces it
|
|
21
|
+
cannot see. `docs/panel-screenshot.md` records the limits.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- `html2canvas` 1.4.1 is vendored at `vendor/html2canvas.min.js` and inlined into
|
|
26
|
+
`lib/client.js` by `pnpm build:client`: the Shell's module table answers `react`
|
|
27
|
+
and `react-dom` only, so a rasteriser has to travel inside the bundle.
|
|
28
|
+
|
|
3
29
|
## [0.7.3] - 2026-10-28
|
|
4
30
|
|
|
5
31
|
### 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: **
|
|
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,349 @@ 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
|
+
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
|
+
|
|
1480
1823
|
return {
|
|
1481
1824
|
inject: ['slots'],
|
|
1482
1825
|
/**
|
|
@@ -1502,6 +1845,25 @@ return {
|
|
|
1502
1845
|
order: 2,
|
|
1503
1846
|
inject: () => ({ configForms }),
|
|
1504
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))
|
|
1505
1867
|
// The account Remote namespace is optional: a Shell with no account still gets
|
|
1506
1868
|
// the Configure page and the cost pill, and only loses the balance line.
|
|
1507
1869
|
ctx.inject(['remote', 'remote.account'], (accountCtx) => {
|
|
@@ -1554,7 +1916,7 @@ return {
|
|
|
1554
1916
|
name: 'shell.overlay',
|
|
1555
1917
|
id: 'polish-github',
|
|
1556
1918
|
inject: () => ({ configForms, createWorkspace }),
|
|
1557
|
-
},
|
|
1919
|
+
}, ShellOverlay))
|
|
1558
1920
|
// The footer keeps the sign-in state, not an add button: adding a
|
|
1559
1921
|
// workspace belongs in the "+" menu, where the Shell says it does.
|
|
1560
1922
|
githubCtx.slots.inject('sidebar.footer.action', () => githubCtx.slots.register({
|
package/client/switches.json
CHANGED
|
@@ -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.
|
package/entries/registry.js
CHANGED
|
@@ -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
|
/**
|