Skip to content

Commit 60a1031

Browse files
authored
feat: expose extensible Desktop plugin surfaces (#227)
* refactor(plugins): simplify surfaces and developer docs feat(desktop): expose Composer control surfaces feat(desktop): add extensible plugin surfaces * fix(desktop): show remote instance action failures
1 parent 268f3b7 commit 60a1031

85 files changed

Lines changed: 4241 additions & 1448 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

desktop/e2e/harness.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export async function launchDesktop(options: LaunchOptions = {}): Promise<Harnes
147147
async waitForConnected(timeoutMs = 30_000) {
148148
await page.waitForFunction(
149149
() => window.__DOTCRAFT_E2E?.connectionStatus?.() === 'connected',
150+
undefined,
150151
{ timeout: timeoutMs }
151152
)
152153
},

desktop/src/renderer/components/conversation/ApprovalDecisionComposer.tsx

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
2+
import type { DesktopPluginComposerSurfaceContext } from '@dotcraft/plugin'
23
import { useT } from '../../contexts/LocaleContext'
34
import type { ApprovalDetailRowSpec, ApprovalOptionSpec, PendingApproval } from '../../stores/conversationStore'
45
import { useConversationStore } from '../../stores/conversationStore'
@@ -7,6 +8,8 @@ import type { ApprovalDecision, ApprovalType } from '../../types/conversation'
78
import { ComposerShell, DECISION_MASCOT } from './ComposerShell'
89
import { ConversationColumn } from './ConversationColumn'
910
import { ComposerChoiceRow } from './ComposerChoiceRow'
11+
import { ComposerToolbarLeadingSlots, ComposerToolbarTrailingSlots } from './ComposerSurfaceSlots'
12+
import { DesktopPluginSurface } from '../desktopPlugins/DesktopPluginSurface'
1013
import { Button } from '../ui/Button'
1114
import {
1215
DEFAULT_COMPOSER_MASCOT_EFFECT_STATE,
@@ -17,6 +20,7 @@ interface ApprovalDecisionComposerProps {
1720
request: PendingApproval
1821
onResponseAccepted?: () => void
1922
mascotEffectState?: ComposerMascotEffectState
23+
desktopPluginSurfaceContext?: DesktopPluginComposerSurfaceContext
2024
}
2125

2226
/** Default tool-approval detail rows (when the request carries no custom `detailRows`). */
@@ -56,7 +60,8 @@ function approvalRequestTarget(request: PendingApproval): {
5660
export function ApprovalDecisionComposer({
5761
request,
5862
onResponseAccepted,
59-
mascotEffectState = DEFAULT_COMPOSER_MASCOT_EFFECT_STATE
63+
mascotEffectState = DEFAULT_COMPOSER_MASCOT_EFFECT_STATE,
64+
desktopPluginSurfaceContext
6065
}: ApprovalDecisionComposerProps): JSX.Element {
6166
const t = useT()
6267
const requestKey = useMemo(() => approvalRequestKey(request), [request])
@@ -203,11 +208,22 @@ export function ApprovalDecisionComposer({
203208

204209
const questionText = request.question ?? t(approvalQuestionKey(request.approvalType))
205210
const detailRows = request.detailRows ?? buildToolDetailRows(request, t)
211+
const mascotSurfaceContext = desktopPluginSurfaceContext ?? {
212+
workspacePath: null,
213+
threadId: request.threadId,
214+
mode: 'agent',
215+
busy: false,
216+
awaitingApproval: true,
217+
variant: 'default',
218+
minimalChrome: false
219+
} as const
206220

207221
return (
208222
<div style={composerDockStyle}>
209223
<ConversationColumn>
224+
<DesktopPluginSurface name="composer.before" context={mascotSurfaceContext} />
210225
<ComposerShell
226+
desktopPluginSurfaceContext={mascotSurfaceContext}
211227
dragOver={false}
212228
dropLabel=""
213229
onDragOver={(e) => e.preventDefault()}
@@ -258,33 +274,46 @@ export function ApprovalDecisionComposer({
258274
</div>
259275
</div>
260276
)}
261-
footerLeading={<div />}
277+
footerLeading={(
278+
<ComposerToolbarLeadingSlots context={mascotSurfaceContext} />
279+
)}
262280
footerAction={(
263-
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
264-
{showFooterReject && (
265-
<Button
266-
variant="ghost"
267-
size="sm"
268-
onClick={() => {
269-
void sendDecision(declineValue)
270-
}}
271-
disabled={locked}
272-
aria-label={t('approval.rejectShortcutAria')}
273-
>
274-
<span>{declineOption?.label ?? t('approval.option.decline.label')}</span>
275-
<span style={kbdChipStyle}>Esc</span>
276-
</Button>
281+
<ComposerToolbarTrailingSlots
282+
context={mascotSurfaceContext}
283+
submit={(
284+
<>
285+
{showFooterReject && (
286+
<Button
287+
variant="ghost"
288+
size="sm"
289+
onClick={() => {
290+
void sendDecision(declineValue)
291+
}}
292+
disabled={locked}
293+
aria-label={t('approval.rejectShortcutAria')}
294+
>
295+
<span>{declineOption?.label ?? t('approval.option.decline.label')}</span>
296+
<span style={kbdChipStyle}>Esc</span>
297+
</Button>
298+
)}
299+
<Button
300+
variant="primary"
301+
onClick={submitSelected}
302+
disabled={locked}
303+
>
304+
{selectedOption.label}
305+
</Button>
306+
</>
277307
)}
278-
<Button
279-
variant="primary"
280-
onClick={submitSelected}
281-
disabled={locked}
282-
>
283-
{selectedOption.label}
284-
</Button>
285-
</div>
308+
/>
309+
)}
310+
belowFooter={(
311+
<DesktopPluginSurface name="composer.status.workspace" context={mascotSurfaceContext}>
312+
<DesktopPluginSurface name="composer.status.subscription" context={mascotSurfaceContext} />
313+
</DesktopPluginSurface>
286314
)}
287315
/>
316+
<DesktopPluginSurface name="composer.after" context={mascotSurfaceContext} />
288317
</ConversationColumn>
289318
</div>
290319
)

desktop/src/renderer/components/conversation/ComposerShell.tsx

Lines changed: 106 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
type ReactNode
1313
} from 'react'
1414
import { Bot, ListChecks, Loader2, Square, X } from 'lucide-react'
15+
import type {
16+
DesktopPluginComposerMascotSurfaceContext,
17+
DesktopPluginComposerSurfaceContext,
18+
DesktopPluginMascotActivity
19+
} from '@dotcraft/plugin'
1520
import { ActionTooltip } from '../ui/ActionTooltip'
21+
import { DesktopPluginSurface } from '../desktopPlugins/DesktopPluginSurface'
1622
import { MascotRobot, type MascotExpression, type MascotLight } from './MascotRobot'
1723
import { mascotPaletteOf, type AvatarSpec } from '../agents/agentAvatar'
1824
import { MascotBubble, type MascotBubbleAction, type MascotBubbleTone } from './MascotBubble'
@@ -89,6 +95,8 @@ interface ComposerShellProps {
8995
focused?: boolean
9096
/** Show the DotCraft mascot standing on the composer's top-right edge. */
9197
showMascot?: boolean
98+
/** Public Composer context inherited by the mascot surface. */
99+
desktopPluginSurfaceContext: DesktopPluginComposerSurfaceContext
92100
/** Monotonic counter; bump on send to trigger the one-shot launch jump. */
93101
mascotBounceSignal?: number
94102
/** State-driven expression/light/bubble/right-click menu for the mascot. */
@@ -116,6 +124,7 @@ const MASCOT_HIDDEN_RATIO = 0.06
116124
const MASCOT_RAISE = 3
117125
/** Ambient idle time before the mascot dozes off (woken by any interaction). */
118126
const MASCOT_SLEEP_AFTER_MS = 90_000
127+
const MASCOT_WAVE_DURATION_MS = 1_600
119128
const MASCOT_ACTIVE_IDLE_MIN_MS = 35_000
120129
const MASCOT_ACTIVE_IDLE_JITTER_MS = 30_000
121130
const MASCOT_ACTIVE_IDLE_ACTIVITY_THROTTLE_MS = 500
@@ -217,6 +226,7 @@ function ComposerMascot({
217226
avatar,
218227
profileTransition,
219228
profileTransitionRevision,
229+
desktopPluginSurfaceContext,
220230
anchorOffset = 0,
221231
anchorPushSignal = 0,
222232
handoff = false
@@ -231,6 +241,7 @@ function ComposerMascot({
231241
avatar?: AvatarSpec
232242
profileTransition: MascotProfileTransition | null
233243
profileTransitionRevision: number
244+
desktopPluginSurfaceContext: DesktopPluginComposerSurfaceContext
234245
/** Height of the active top accessory; the mascot stands on its upper edge. */
235246
anchorOffset?: number
236247
/** Monotonic signal fired when an expanding accessory finishes pushing upward. */
@@ -276,6 +287,33 @@ function ComposerMascot({
276287
// Local behaviors (sleep, wave) override the face; conversation light stays.
277288
const expression: MascotExpression = sleeping ? 'sleep' : waving ? 'happy' : baseExpression
278289
const mascotPalette = mascotPaletteOf(avatar)
290+
const activity: DesktopPluginMascotActivity = light === 'error'
291+
? 'error'
292+
: light === 'success'
293+
? 'success'
294+
: sleeping
295+
? 'sleeping'
296+
: dragOver
297+
? 'dragging'
298+
: holdSign
299+
? 'decision'
300+
: baseExpression === 'operator'
301+
? 'working'
302+
: focused
303+
? 'focused'
304+
: 'idle'
305+
const desktopPluginMascotContext: DesktopPluginComposerMascotSurfaceContext = {
306+
...desktopPluginSurfaceContext,
307+
size: MASCOT_SIZE,
308+
activity,
309+
expression,
310+
light,
311+
submitRevision: bounceSignal,
312+
reasoningEffort,
313+
speed,
314+
contextMax,
315+
reducedMotion: prefersReducedMotion()
316+
}
279317
const ambient =
280318
!focused &&
281319
!dragOver &&
@@ -526,6 +564,14 @@ function ComposerMascot({
526564
}
527565
}, [sleeping, baseExpression, activeIdle])
528566

567+
// The visual character is replaceable, so the greeting lifecycle cannot rely
568+
// only on an animation event emitted by the default SVG's arm nodes.
569+
useEffect(() => {
570+
if (!waving) return
571+
const timer = window.setTimeout(() => setWaving(false), MASCOT_WAVE_DURATION_MS)
572+
return () => window.clearTimeout(timer)
573+
}, [waving])
574+
529575
// Typing nod: keystrokes land here only while the composer editor is focused;
530576
// the animation's own duration throttles the cadence.
531577
useEffect(() => {
@@ -585,6 +631,7 @@ function ComposerMascot({
585631
// Held-sign variant: the arm rocks the gripped "?" sign instead of fanning
586632
// an empty hand; same one-shot lifecycle, different keyframes.
587633
case 'composer-mascot-sign-wave-arm':
634+
case 'composer-mascot-wave-lean':
588635
setWaving(false)
589636
break
590637
case 'composer-mascot-nod':
@@ -744,7 +791,11 @@ function ComposerMascot({
744791
}
745792
>
746793
<div className="composer-mascot-fast-echo">
747-
<MascotRobot expression={expression} light={light} size={MASCOT_SIZE} avatar={avatar} />
794+
<div className="composer-mascot-character-stage">
795+
<DesktopPluginSurface name="composer.mascot" context={desktopPluginMascotContext}>
796+
<MascotRobot expression={expression} light={light} size={MASCOT_SIZE} avatar={avatar} />
797+
</DesktopPluginSurface>
798+
</div>
748799
</div>
749800
</div>
750801
</div>
@@ -802,6 +853,7 @@ export function ComposerShell({
802853
opacity = 1,
803854
focused = false,
804855
showMascot = false,
856+
desktopPluginSurfaceContext,
805857
mascotBounceSignal = 0,
806858
mascotInteraction,
807859
mascotReasoningEffort = 'off',
@@ -935,6 +987,7 @@ export function ComposerShell({
935987
avatar={renderedMascotAvatar}
936988
profileTransition={mascotProfileTransition}
937989
profileTransitionRevision={mascotProfileTransitionRevision}
990+
desktopPluginSurfaceContext={desktopPluginSurfaceContext}
938991
anchorOffset={topAccessoryHeight}
939992
anchorPushSignal={topAccessoryPushSignal}
940993
handoff={mascotHandoff}
@@ -1051,35 +1104,60 @@ export function ComposerShell({
10511104
</div>
10521105
)}
10531106

1054-
{attachmentStrip}
1055-
{editor}
1056-
1057-
<div
1058-
style={{
1059-
display: 'flex',
1060-
alignItems: 'center',
1061-
justifyContent: 'space-between',
1062-
gap: '10px',
1063-
marginTop: '8px',
1064-
paddingTop: '6px'
1065-
}}
1066-
>
1067-
{footerLeading}
1068-
{footerAction}
1069-
</div>
1107+
<DesktopPluginSurface name="composer.input" context={desktopPluginSurfaceContext}>
1108+
<DesktopPluginSurface name="composer.input.attachments" context={desktopPluginSurfaceContext}>
1109+
{attachmentStrip}
1110+
</DesktopPluginSurface>
1111+
<DesktopPluginSurface name="composer.input.editor" context={desktopPluginSurfaceContext}>
1112+
{editor}
1113+
</DesktopPluginSurface>
1114+
</DesktopPluginSurface>
1115+
1116+
<DesktopPluginSurface name="composer.toolbar" context={desktopPluginSurfaceContext}>
1117+
<div
1118+
style={{
1119+
display: 'flex',
1120+
alignItems: 'center',
1121+
justifyContent: 'space-between',
1122+
gap: '10px',
1123+
marginTop: '8px',
1124+
paddingTop: '6px'
1125+
}}
1126+
>
1127+
<div
1128+
data-composer-toolbar-leading
1129+
style={{
1130+
display: 'flex',
1131+
alignItems: 'center',
1132+
gap: '10px',
1133+
minWidth: 0,
1134+
flex: '1 1 auto'
1135+
}}
1136+
>
1137+
<DesktopPluginSurface name="composer.toolbar.leading" context={desktopPluginSurfaceContext}>
1138+
{footerLeading}
1139+
</DesktopPluginSurface>
1140+
</div>
1141+
<div
1142+
data-composer-toolbar-trailing
1143+
style={{
1144+
display: 'flex',
1145+
alignItems: 'center',
1146+
gap: '10px',
1147+
flex: '0 0 auto'
1148+
}}
1149+
>
1150+
<DesktopPluginSurface name="composer.toolbar.trailing" context={desktopPluginSurfaceContext}>
1151+
{footerAction}
1152+
</DesktopPluginSurface>
1153+
</div>
1154+
</div>
1155+
</DesktopPluginSurface>
10701156
</div>
10711157
</div>
1072-
{belowFooter && (
1073-
<div
1074-
style={{
1075-
position: 'relative',
1076-
zIndex: 1,
1077-
marginTop: '6px'
1078-
}}
1079-
>
1080-
{belowFooter}
1081-
</div>
1082-
)}
1158+
<DesktopPluginSurface name="composer.status" context={desktopPluginSurfaceContext}>
1159+
{belowFooter}
1160+
</DesktopPluginSurface>
10831161
</div>
10841162
)
10851163
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
.status {
2+
position: relative;
3+
z-index: 1;
4+
display: flex;
5+
gap: 8px;
6+
align-items: center;
7+
}
8+
9+
.topSpacing {
10+
margin-top: 6px;
11+
}
12+
13+
.workspace {
14+
flex: 1;
15+
min-width: 0;
16+
}

0 commit comments

Comments
 (0)