@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 +37 -0
- package/README.md +2 -1
- package/client/face.js +378 -1
- package/client/switches.json +6 -0
- package/docs/panel-screenshot.md +98 -0
- package/entries/registry.js +6 -0
- package/lib/client.js +408 -2
- package/package.json +4 -2
- package/vendor/html2canvas.min.js +20 -0
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: **
|
|
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
|
-
},
|
|
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({
|
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
|
/**
|