@milkdown/crepe 7.19.2 → 7.21.0
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/lib/cjs/builder.js +44 -1
- package/lib/cjs/builder.js.map +1 -1
- package/lib/cjs/feature/ai/index.js +1492 -0
- package/lib/cjs/feature/ai/index.js.map +1 -0
- package/lib/cjs/feature/block-edit/index.js +9 -2
- package/lib/cjs/feature/block-edit/index.js.map +1 -1
- package/lib/cjs/feature/code-mirror/index.js +2 -0
- package/lib/cjs/feature/code-mirror/index.js.map +1 -1
- package/lib/cjs/feature/cursor/index.js +2 -0
- package/lib/cjs/feature/cursor/index.js.map +1 -1
- package/lib/cjs/feature/image-block/index.js +5 -1
- package/lib/cjs/feature/image-block/index.js.map +1 -1
- package/lib/cjs/feature/latex/index.js +7 -0
- package/lib/cjs/feature/latex/index.js.map +1 -1
- package/lib/cjs/feature/link-tooltip/index.js +2 -0
- package/lib/cjs/feature/link-tooltip/index.js.map +1 -1
- package/lib/cjs/feature/list-item/index.js +2 -0
- package/lib/cjs/feature/list-item/index.js.map +1 -1
- package/lib/cjs/feature/placeholder/index.js +2 -0
- package/lib/cjs/feature/placeholder/index.js.map +1 -1
- package/lib/cjs/feature/table/index.js +2 -0
- package/lib/cjs/feature/table/index.js.map +1 -1
- package/lib/cjs/feature/toolbar/index.js +497 -5
- package/lib/cjs/feature/toolbar/index.js.map +1 -1
- package/lib/cjs/feature/top-bar/index.js +791 -0
- package/lib/cjs/feature/top-bar/index.js.map +1 -0
- package/lib/cjs/index.js +2047 -160
- package/lib/cjs/index.js.map +1 -1
- package/lib/cjs/llm-providers/anthropic/index.js +147 -0
- package/lib/cjs/llm-providers/anthropic/index.js.map +1 -0
- package/lib/cjs/llm-providers/openai/index.js +138 -0
- package/lib/cjs/llm-providers/openai/index.js.map +1 -0
- package/lib/esm/builder.js +44 -1
- package/lib/esm/builder.js.map +1 -1
- package/lib/esm/feature/ai/index.js +1487 -0
- package/lib/esm/feature/ai/index.js.map +1 -0
- package/lib/esm/feature/block-edit/index.js +9 -2
- package/lib/esm/feature/block-edit/index.js.map +1 -1
- package/lib/esm/feature/code-mirror/index.js +2 -0
- package/lib/esm/feature/code-mirror/index.js.map +1 -1
- package/lib/esm/feature/cursor/index.js +2 -0
- package/lib/esm/feature/cursor/index.js.map +1 -1
- package/lib/esm/feature/image-block/index.js +5 -1
- package/lib/esm/feature/image-block/index.js.map +1 -1
- package/lib/esm/feature/latex/index.js +7 -0
- package/lib/esm/feature/latex/index.js.map +1 -1
- package/lib/esm/feature/link-tooltip/index.js +2 -0
- package/lib/esm/feature/link-tooltip/index.js.map +1 -1
- package/lib/esm/feature/list-item/index.js +2 -0
- package/lib/esm/feature/list-item/index.js.map +1 -1
- package/lib/esm/feature/placeholder/index.js +2 -0
- package/lib/esm/feature/placeholder/index.js.map +1 -1
- package/lib/esm/feature/table/index.js +2 -0
- package/lib/esm/feature/table/index.js.map +1 -1
- package/lib/esm/feature/toolbar/index.js +499 -7
- package/lib/esm/feature/toolbar/index.js.map +1 -1
- package/lib/esm/feature/top-bar/index.js +789 -0
- package/lib/esm/feature/top-bar/index.js.map +1 -0
- package/lib/esm/index.js +2040 -153
- package/lib/esm/index.js.map +1 -1
- package/lib/esm/llm-providers/anthropic/index.js +145 -0
- package/lib/esm/llm-providers/anthropic/index.js.map +1 -0
- package/lib/esm/llm-providers/openai/index.js +136 -0
- package/lib/esm/llm-providers/openai/index.js.map +1 -0
- package/lib/theme/common/ai.css +446 -0
- package/lib/theme/common/code-mirror.css +14 -0
- package/lib/theme/common/diff.css +177 -0
- package/lib/theme/common/style.css +3 -0
- package/lib/theme/common/top-bar.css +152 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/core/builder.d.ts +2 -1
- package/lib/types/core/builder.d.ts.map +1 -1
- package/lib/types/feature/ai/ai.spec.d.ts +2 -0
- package/lib/types/feature/ai/ai.spec.d.ts.map +1 -0
- package/lib/types/feature/ai/commands.d.ts +24 -0
- package/lib/types/feature/ai/commands.d.ts.map +1 -0
- package/lib/types/feature/ai/context.d.ts +4 -0
- package/lib/types/feature/ai/context.d.ts.map +1 -0
- package/lib/types/feature/ai/diff-actions/index.d.ts +12 -0
- package/lib/types/feature/ai/diff-actions/index.d.ts.map +1 -0
- package/lib/types/feature/ai/diff-actions/view.d.ts +21 -0
- package/lib/types/feature/ai/diff-actions/view.d.ts.map +1 -0
- package/lib/types/feature/ai/index.d.ts +7 -0
- package/lib/types/feature/ai/index.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/component.d.ts +26 -0
- package/lib/types/feature/ai/instruction-tooltip/component.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/index.d.ts +17 -0
- package/lib/types/feature/ai/instruction-tooltip/index.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts +50 -0
- package/lib/types/feature/ai/instruction-tooltip/suggestions.d.ts.map +1 -0
- package/lib/types/feature/ai/instruction-tooltip/view.d.ts +19 -0
- package/lib/types/feature/ai/instruction-tooltip/view.d.ts.map +1 -0
- package/lib/types/feature/ai/streaming-indicator.d.ts +9 -0
- package/lib/types/feature/ai/streaming-indicator.d.ts.map +1 -0
- package/lib/types/feature/ai/types.d.ts +58 -0
- package/lib/types/feature/ai/types.d.ts.map +1 -0
- package/lib/types/feature/block-edit/handle/component.d.ts.map +1 -1
- package/lib/types/feature/block-edit/menu/component.d.ts.map +1 -1
- package/lib/types/feature/image-block/index.d.ts +2 -0
- package/lib/types/feature/image-block/index.d.ts.map +1 -1
- package/lib/types/feature/index.d.ts +7 -1
- package/lib/types/feature/index.d.ts.map +1 -1
- package/lib/types/feature/latex/inline-tooltip/component.d.ts.map +1 -1
- package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts +2 -0
- package/lib/types/feature/latex/inline-tooltip/inline-tooltip.spec.d.ts.map +1 -0
- package/lib/types/feature/latex/inline-tooltip/view.d.ts.map +1 -1
- package/lib/types/feature/loader.d.ts.map +1 -1
- package/lib/types/feature/toolbar/component.d.ts.map +1 -1
- package/lib/types/feature/toolbar/config.d.ts +1 -1
- package/lib/types/feature/toolbar/config.d.ts.map +1 -1
- package/lib/types/feature/toolbar/index.d.ts +1 -0
- package/lib/types/feature/toolbar/index.d.ts.map +1 -1
- package/lib/types/feature/top-bar/component.d.ts +11 -0
- package/lib/types/feature/top-bar/component.d.ts.map +1 -0
- package/lib/types/feature/top-bar/config.d.ts +34 -0
- package/lib/types/feature/top-bar/config.d.ts.map +1 -0
- package/lib/types/feature/top-bar/index.d.ts +26 -0
- package/lib/types/feature/top-bar/index.d.ts.map +1 -0
- package/lib/types/icons/ai.d.ts +2 -0
- package/lib/types/icons/ai.d.ts.map +1 -0
- package/lib/types/icons/chevron-left.d.ts +2 -0
- package/lib/types/icons/chevron-left.d.ts.map +1 -0
- package/lib/types/icons/chevron-right.d.ts +2 -0
- package/lib/types/icons/chevron-right.d.ts.map +1 -0
- package/lib/types/icons/code-block.d.ts +2 -0
- package/lib/types/icons/code-block.d.ts.map +1 -0
- package/lib/types/icons/enter-key.d.ts +2 -0
- package/lib/types/icons/enter-key.d.ts.map +1 -0
- package/lib/types/icons/grammar-check.d.ts +2 -0
- package/lib/types/icons/grammar-check.d.ts.map +1 -0
- package/lib/types/icons/index.d.ts +12 -0
- package/lib/types/icons/index.d.ts.map +1 -1
- package/lib/types/icons/longer.d.ts +2 -0
- package/lib/types/icons/longer.d.ts.map +1 -0
- package/lib/types/icons/retry.d.ts +2 -0
- package/lib/types/icons/retry.d.ts.map +1 -0
- package/lib/types/icons/send-prompt.d.ts +2 -0
- package/lib/types/icons/send-prompt.d.ts.map +1 -0
- package/lib/types/icons/send.d.ts +2 -0
- package/lib/types/icons/send.d.ts.map +1 -0
- package/lib/types/icons/shorter.d.ts +2 -0
- package/lib/types/icons/shorter.d.ts.map +1 -0
- package/lib/types/icons/translate.d.ts +2 -0
- package/lib/types/icons/translate.d.ts.map +1 -0
- package/lib/types/llm-providers/anthropic/index.d.ts +21 -0
- package/lib/types/llm-providers/anthropic/index.d.ts.map +1 -0
- package/lib/types/llm-providers/openai/index.d.ts +15 -0
- package/lib/types/llm-providers/openai/index.d.ts.map +1 -0
- package/lib/types/llm-providers/providers.spec.d.ts +2 -0
- package/lib/types/llm-providers/providers.spec.d.ts.map +1 -0
- package/lib/types/llm-providers/shared.d.ts +16 -0
- package/lib/types/llm-providers/shared.d.ts.map +1 -0
- package/lib/types/utils/group-builder.d.ts +1 -1
- package/lib/types/utils/group-builder.d.ts.map +1 -1
- package/lib/types/utils/keep-alive.d.ts +2 -0
- package/lib/types/utils/keep-alive.d.ts.map +1 -0
- package/package.json +34 -13
- package/src/core/builder.ts +39 -2
- package/src/feature/ai/ai.spec.ts +742 -0
- package/src/feature/ai/commands.ts +257 -0
- package/src/feature/ai/context.ts +45 -0
- package/src/feature/ai/diff-actions/index.ts +95 -0
- package/src/feature/ai/diff-actions/view.ts +237 -0
- package/src/feature/ai/index.ts +118 -0
- package/src/feature/ai/instruction-tooltip/component.tsx +414 -0
- package/src/feature/ai/instruction-tooltip/index.ts +101 -0
- package/src/feature/ai/instruction-tooltip/suggestions.ts +249 -0
- package/src/feature/ai/instruction-tooltip/view.ts +159 -0
- package/src/feature/ai/streaming-indicator.ts +183 -0
- package/src/feature/ai/types.ts +178 -0
- package/src/feature/block-edit/handle/component.tsx +3 -2
- package/src/feature/block-edit/menu/component.tsx +3 -2
- package/src/feature/block-edit/menu/config.ts +1 -1
- package/src/feature/image-block/index.ts +4 -0
- package/src/feature/index.ts +14 -2
- package/src/feature/latex/inline-tooltip/component.tsx +4 -2
- package/src/feature/latex/inline-tooltip/inline-tooltip.spec.ts +81 -0
- package/src/feature/latex/inline-tooltip/view.ts +2 -0
- package/src/feature/loader.ts +8 -0
- package/src/feature/toolbar/component.tsx +7 -5
- package/src/feature/toolbar/config.ts +27 -1
- package/src/feature/toolbar/index.ts +1 -0
- package/src/feature/top-bar/component.tsx +198 -0
- package/src/feature/top-bar/config.ts +367 -0
- package/src/feature/top-bar/index.ts +113 -0
- package/src/icons/ai.ts +14 -0
- package/src/icons/chevron-left.ts +15 -0
- package/src/icons/chevron-right.ts +15 -0
- package/src/icons/code-block.ts +12 -0
- package/src/icons/enter-key.ts +13 -0
- package/src/icons/grammar-check.ts +13 -0
- package/src/icons/index.ts +12 -0
- package/src/icons/longer.ts +13 -0
- package/src/icons/retry.ts +13 -0
- package/src/icons/send-prompt.ts +13 -0
- package/src/icons/send.ts +13 -0
- package/src/icons/shorter.ts +13 -0
- package/src/icons/translate.ts +13 -0
- package/src/llm-providers/anthropic/index.ts +132 -0
- package/src/llm-providers/openai/index.ts +109 -0
- package/src/llm-providers/providers.spec.ts +472 -0
- package/src/llm-providers/shared.ts +160 -0
- package/src/theme/common/ai.css +430 -0
- package/src/theme/common/code-mirror.css +14 -0
- package/src/theme/common/diff.css +196 -0
- package/src/theme/common/style.css +3 -0
- package/src/theme/common/top-bar.css +156 -0
- package/src/utils/group-builder.ts +1 -1
- package/src/utils/keep-alive.ts +3 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { Ctx } from '@milkdown/kit/ctx'
|
|
2
|
+
import type { MilkdownError } from '@milkdown/kit/exception'
|
|
3
|
+
|
|
4
|
+
import { commandsCtx } from '@milkdown/kit/core'
|
|
5
|
+
import { aiBuildContextError, aiProviderError } from '@milkdown/kit/exception'
|
|
6
|
+
import { diffPluginKey } from '@milkdown/kit/plugin/diff'
|
|
7
|
+
import {
|
|
8
|
+
abortStreamingCmd,
|
|
9
|
+
endStreamingCmd,
|
|
10
|
+
pushChunkCmd,
|
|
11
|
+
startStreamingCmd,
|
|
12
|
+
streamingPluginKey,
|
|
13
|
+
} from '@milkdown/kit/plugin/streaming'
|
|
14
|
+
import { $command, $ctx } from '@milkdown/kit/utils'
|
|
15
|
+
|
|
16
|
+
import type { AIProvider, AIPromptContext, RunAIOptions } from './types'
|
|
17
|
+
|
|
18
|
+
import { defaultBuildContext } from './context'
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Context slices
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/// Holds the user-supplied provider and prompt builder. Populated by
|
|
25
|
+
/// the AI feature's setup function from `AIFeatureConfig`. `aiIcon`
|
|
26
|
+
/// lives here too so that other features (notably the toolbar) can pick
|
|
27
|
+
/// up the AI feature's icon override at render time.
|
|
28
|
+
export const aiProviderConfig = $ctx(
|
|
29
|
+
{
|
|
30
|
+
provider: undefined as AIProvider | undefined,
|
|
31
|
+
buildContext: undefined as
|
|
32
|
+
| ((ctx: Ctx, instruction: string) => AIPromptContext)
|
|
33
|
+
| undefined,
|
|
34
|
+
diffReviewOnEnd: true,
|
|
35
|
+
onError: (error: MilkdownError) => {
|
|
36
|
+
console.error(`[milkdown/ai] [${error.code}]`, error)
|
|
37
|
+
},
|
|
38
|
+
aiIcon: undefined as string | undefined,
|
|
39
|
+
},
|
|
40
|
+
'aiProviderConfig'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
/// Holds the AbortController and active-form label for the current AI
|
|
44
|
+
/// session (null/empty when idle). `label` is shown in the streaming
|
|
45
|
+
/// indicator. `lastInstruction`, `lastLabel`, `lastFrom`, `lastTo` are
|
|
46
|
+
/// kept after the session ends so the diff-actions Retry button can
|
|
47
|
+
/// re-run the same prompt on the same text range. `diffOwnedByAI` is
|
|
48
|
+
/// flipped on right before our `endStreamingCmd` activates diff review
|
|
49
|
+
/// and back off when the diff panel sees the diff close, so a manually
|
|
50
|
+
/// started diff review (via `startDiffReviewCmd`) doesn't inherit the
|
|
51
|
+
/// previous AI session's Retry affordance.
|
|
52
|
+
export const aiSessionCtx = $ctx(
|
|
53
|
+
{
|
|
54
|
+
abortController: null as AbortController | null,
|
|
55
|
+
label: '',
|
|
56
|
+
lastInstruction: '',
|
|
57
|
+
lastLabel: undefined as string | undefined,
|
|
58
|
+
lastFrom: -1,
|
|
59
|
+
lastTo: -1,
|
|
60
|
+
diffOwnedByAI: false,
|
|
61
|
+
},
|
|
62
|
+
'aiSession'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function emitAIError(ctx: Ctx, error: MilkdownError): void {
|
|
70
|
+
const config = ctx.get(aiProviderConfig.key)
|
|
71
|
+
try {
|
|
72
|
+
config.onError(error)
|
|
73
|
+
} catch (handlerError) {
|
|
74
|
+
console.error('[milkdown/ai] onError handler failed:', handlerError)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Clear the live (`abortController`, `label`) portion of the session
|
|
79
|
+
/// while preserving the `last*` fields used by the diff-actions Retry
|
|
80
|
+
/// button.
|
|
81
|
+
function clearActiveSession(ctx: Ctx): void {
|
|
82
|
+
const current = ctx.get(aiSessionCtx.key)
|
|
83
|
+
ctx.set(aiSessionCtx.key, {
|
|
84
|
+
...current,
|
|
85
|
+
abortController: null,
|
|
86
|
+
label: '',
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Async provider runner
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
async function runProvider(
|
|
95
|
+
ctx: Ctx,
|
|
96
|
+
provider: AIProvider,
|
|
97
|
+
promptContext: AIPromptContext,
|
|
98
|
+
abortController: AbortController
|
|
99
|
+
): Promise<void> {
|
|
100
|
+
try {
|
|
101
|
+
const iterable = provider(promptContext, abortController.signal)
|
|
102
|
+
const commands = ctx.get(commandsCtx)
|
|
103
|
+
for await (const chunk of iterable) {
|
|
104
|
+
if (abortController.signal.aborted) break
|
|
105
|
+
commands.call(pushChunkCmd.key, chunk)
|
|
106
|
+
}
|
|
107
|
+
if (abortController.signal.aborted) return
|
|
108
|
+
// Streaming complete — hand off to diff review if configured. The
|
|
109
|
+
// ownership flag is flipped *before* the dispatch so the diff-actions
|
|
110
|
+
// panel reads `true` during the same transaction's update cycle.
|
|
111
|
+
// If the dispatch is rejected (e.g. host code already ended the
|
|
112
|
+
// streaming session), revert the flag so the next manually-started
|
|
113
|
+
// diff review isn't misclassified as AI-owned — `clearActiveSession`
|
|
114
|
+
// intentionally preserves `diffOwnedByAI` because the panel is
|
|
115
|
+
// responsible for clearing it on the diff true→false edge, but no
|
|
116
|
+
// such edge fires when the dispatch never landed.
|
|
117
|
+
const config = ctx.get(aiProviderConfig.key)
|
|
118
|
+
if (config.diffReviewOnEnd) {
|
|
119
|
+
const cur = ctx.get(aiSessionCtx.key)
|
|
120
|
+
ctx.set(aiSessionCtx.key, { ...cur, diffOwnedByAI: true })
|
|
121
|
+
}
|
|
122
|
+
const dispatched = commands.call(endStreamingCmd.key, {
|
|
123
|
+
diffReview: config.diffReviewOnEnd,
|
|
124
|
+
})
|
|
125
|
+
if (config.diffReviewOnEnd && !dispatched) {
|
|
126
|
+
const cur = ctx.get(aiSessionCtx.key)
|
|
127
|
+
ctx.set(aiSessionCtx.key, { ...cur, diffOwnedByAI: false })
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (abortController.signal.aborted) return
|
|
131
|
+
const milkdownError = aiProviderError(error)
|
|
132
|
+
emitAIError(ctx, milkdownError)
|
|
133
|
+
const commands = ctx.get(commandsCtx)
|
|
134
|
+
commands.call(abortStreamingCmd.key, { keep: false })
|
|
135
|
+
} finally {
|
|
136
|
+
// Only clean up if this session is still the active one. If the
|
|
137
|
+
// user aborted and immediately started a new session, the new
|
|
138
|
+
// session owns the ctx now and we must not clobber it.
|
|
139
|
+
const current = ctx.get(aiSessionCtx.key)
|
|
140
|
+
if (current.abortController === abortController) {
|
|
141
|
+
clearActiveSession(ctx)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Commands
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
/// Start an AI session: capture context, start streaming, and call the
|
|
151
|
+
/// provider asynchronously. The command returns synchronously; the
|
|
152
|
+
/// provider runs in the background.
|
|
153
|
+
///
|
|
154
|
+
/// When the user has a text selection, the streamed output replaces the
|
|
155
|
+
/// selected text. The provider also receives the selected text in
|
|
156
|
+
/// `AIPromptContext.selection` for context-aware generation.
|
|
157
|
+
export const runAICmd = $command('RunAI', (ctx) => {
|
|
158
|
+
return (options?: RunAIOptions) => (state, dispatch) => {
|
|
159
|
+
if (!options?.instruction) return false
|
|
160
|
+
|
|
161
|
+
const config = ctx.get(aiProviderConfig.key)
|
|
162
|
+
if (!config.provider) return false
|
|
163
|
+
|
|
164
|
+
// Reject if a session is already running, streaming is active, or
|
|
165
|
+
// diff review is active (the diff plugin blocks non-diff transactions,
|
|
166
|
+
// so streaming flushes would be silently rejected).
|
|
167
|
+
const session = ctx.get(aiSessionCtx.key)
|
|
168
|
+
if (session.abortController) return false
|
|
169
|
+
if (streamingPluginKey.getState(state)?.active) return false
|
|
170
|
+
if (diffPluginKey.getState(state)?.active) return false
|
|
171
|
+
|
|
172
|
+
// Dry-run: when dispatch is undefined, ProseMirror is probing
|
|
173
|
+
// whether this command can execute. All precondition checks above
|
|
174
|
+
// are side-effect-free so we can return true here.
|
|
175
|
+
if (!dispatch) return true
|
|
176
|
+
|
|
177
|
+
// Set the session label before starting the streaming plugin, so
|
|
178
|
+
// the streaming-indicator widget reads the right label when its
|
|
179
|
+
// decoration is first built. Empty string means "no caller-supplied
|
|
180
|
+
// label" — the indicator widget falls through to its configured
|
|
181
|
+
// `fallbackLabel`. `lastInstruction` / `lastLabel` are also stored
|
|
182
|
+
// here so the diff-actions Retry button can re-run the same prompt
|
|
183
|
+
// later.
|
|
184
|
+
const abortController = new AbortController()
|
|
185
|
+
const { from, to } = state.selection
|
|
186
|
+
ctx.set(aiSessionCtx.key, {
|
|
187
|
+
abortController,
|
|
188
|
+
label: options.label ?? '',
|
|
189
|
+
lastInstruction: options.instruction,
|
|
190
|
+
lastLabel: options.label,
|
|
191
|
+
lastFrom: from,
|
|
192
|
+
lastTo: to,
|
|
193
|
+
// Reset every run; only the success path that hands off to diff
|
|
194
|
+
// review flips it back on.
|
|
195
|
+
diffOwnedByAI: false,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Start streaming — replaces the selection if non-empty.
|
|
199
|
+
const commands = ctx.get(commandsCtx)
|
|
200
|
+
const insertAt = state.selection.empty
|
|
201
|
+
? ('cursor' as const)
|
|
202
|
+
: ('selection' as const)
|
|
203
|
+
if (!commands.call(startStreamingCmd.key, { insertAt })) {
|
|
204
|
+
clearActiveSession(ctx)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Everything after startStreamingCmd is wrapped in try/catch: if
|
|
209
|
+
// buildContext or anything else throws, we must abort the streaming
|
|
210
|
+
// session to avoid leaving the editor locked with no way to recover.
|
|
211
|
+
let promptContext: AIPromptContext
|
|
212
|
+
try {
|
|
213
|
+
const buildContext = config.buildContext ?? defaultBuildContext
|
|
214
|
+
promptContext = buildContext(ctx, options.instruction)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const milkdownError = aiBuildContextError(error)
|
|
217
|
+
emitAIError(ctx, milkdownError)
|
|
218
|
+
commands.call(abortStreamingCmd.key, { keep: false })
|
|
219
|
+
clearActiveSession(ctx)
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fire-and-forget: the provider pushes chunks asynchronously.
|
|
224
|
+
// startStreamingCmd already dispatched its own transaction — we
|
|
225
|
+
// must NOT dispatch state.tr here as it would overwrite the
|
|
226
|
+
// streaming plugin's state with a stale doc.
|
|
227
|
+
void runProvider(ctx, config.provider, promptContext, abortController)
|
|
228
|
+
|
|
229
|
+
return true
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
/// Abort the current AI session. Signals the provider to stop and
|
|
234
|
+
/// delegates to `abortStreamingCmd` if streaming is still active.
|
|
235
|
+
/// Returns true whenever an AI session was actually cleaned up.
|
|
236
|
+
export const abortAICmd = $command('AbortAI', (ctx) => {
|
|
237
|
+
return (options?: { keep?: boolean }) => (state, dispatch) => {
|
|
238
|
+
const session = ctx.get(aiSessionCtx.key)
|
|
239
|
+
// Dry-run: return whether there's something to abort, without
|
|
240
|
+
// performing any side effects.
|
|
241
|
+
if (!dispatch) return !!session.abortController
|
|
242
|
+
|
|
243
|
+
if (!session.abortController) return false
|
|
244
|
+
|
|
245
|
+
session.abortController.abort()
|
|
246
|
+
clearActiveSession(ctx)
|
|
247
|
+
|
|
248
|
+
// Only call abortStreamingCmd if the streaming plugin is still
|
|
249
|
+
// active — it may have already finished/errored by the time the
|
|
250
|
+
// user clicks abort.
|
|
251
|
+
if (streamingPluginKey.getState(state)?.active) {
|
|
252
|
+
const commands = ctx.get(commandsCtx)
|
|
253
|
+
commands.call(abortStreamingCmd.key, options)
|
|
254
|
+
}
|
|
255
|
+
return true
|
|
256
|
+
}
|
|
257
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Ctx } from '@milkdown/kit/ctx'
|
|
2
|
+
|
|
3
|
+
import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
|
|
4
|
+
|
|
5
|
+
import type { AIPromptContext } from './types'
|
|
6
|
+
|
|
7
|
+
/// Default prompt context builder. Serializes the full document and
|
|
8
|
+
/// the current selection (if any) as markdown, then pairs them with
|
|
9
|
+
/// the user instruction.
|
|
10
|
+
export function defaultBuildContext(
|
|
11
|
+
ctx: Ctx,
|
|
12
|
+
instruction: string
|
|
13
|
+
): AIPromptContext {
|
|
14
|
+
const view = ctx.get(editorViewCtx)
|
|
15
|
+
const serializer = ctx.get(serializerCtx)
|
|
16
|
+
const { state } = view
|
|
17
|
+
|
|
18
|
+
// Full document as markdown
|
|
19
|
+
const document = serializer(state.doc)
|
|
20
|
+
|
|
21
|
+
// Selected text as markdown (empty if selection is collapsed).
|
|
22
|
+
// For block-level selections (whole paragraphs, list items, etc.)
|
|
23
|
+
// we wrap the slice content in a doc node and serialize it. For
|
|
24
|
+
// inline-only selections (text inside a single paragraph),
|
|
25
|
+
// createAndFill on the doc type returns null because inline content
|
|
26
|
+
// isn't valid as direct doc children — wrap it in a paragraph first
|
|
27
|
+
// so marks (bold, italic, links) survive into the markdown output.
|
|
28
|
+
let selection = ''
|
|
29
|
+
if (!state.selection.empty) {
|
|
30
|
+
const { from, to } = state.selection
|
|
31
|
+
const slice = state.doc.slice(from, to)
|
|
32
|
+
const { schema } = state.doc.type
|
|
33
|
+
let wrapper = schema.topNodeType.createAndFill(null, slice.content)
|
|
34
|
+
if (!wrapper) {
|
|
35
|
+
const paragraph = schema.nodes.paragraph?.createAndFill(
|
|
36
|
+
null,
|
|
37
|
+
slice.content
|
|
38
|
+
)
|
|
39
|
+
if (paragraph) wrapper = schema.topNodeType.createAndFill(null, paragraph)
|
|
40
|
+
}
|
|
41
|
+
selection = wrapper ? serializer(wrapper) : state.doc.textBetween(from, to)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { document, selection, instruction }
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { commandsCtx } from '@milkdown/kit/core'
|
|
2
|
+
import { acceptAllDiffsCmd, diffPluginKey } from '@milkdown/kit/plugin/diff'
|
|
3
|
+
import { Plugin, PluginKey } from '@milkdown/kit/prose/state'
|
|
4
|
+
import { $prose } from '@milkdown/kit/utils'
|
|
5
|
+
|
|
6
|
+
import type { AIDiffActionsConfig } from '../types'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
clearIcon as defaultRejectIcon,
|
|
10
|
+
confirmIcon as defaultAcceptIcon,
|
|
11
|
+
enterKeyIcon as defaultEnterKeyIcon,
|
|
12
|
+
retryIcon as defaultRetryIcon,
|
|
13
|
+
} from '../../../icons'
|
|
14
|
+
import { aiSessionCtx } from '../commands'
|
|
15
|
+
import { DiffActionsPanelView, type ResolvedDiffActionsConfig } from './view'
|
|
16
|
+
|
|
17
|
+
const diffActionsPanelKey = new PluginKey('CREPE_AI_DIFF_ACTIONS_PANEL')
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_DIFF_ACTIONS_RETRY_LABEL = 'Retry'
|
|
20
|
+
export const DEFAULT_DIFF_ACTIONS_REJECT_ALL_LABEL = 'Reject all'
|
|
21
|
+
export const DEFAULT_DIFF_ACTIONS_ACCEPT_ALL_LABEL = 'Accept all'
|
|
22
|
+
|
|
23
|
+
function detectModSymbol(): string {
|
|
24
|
+
if (typeof navigator === 'undefined') return '⌘'
|
|
25
|
+
// `userAgentData.platform` is the modern accessor; fall back to the
|
|
26
|
+
// legacy `platform` / `userAgent` strings for older browsers and
|
|
27
|
+
// jsdom-based test environments.
|
|
28
|
+
const ua = navigator as unknown as {
|
|
29
|
+
userAgentData?: { platform?: string }
|
|
30
|
+
platform?: string
|
|
31
|
+
userAgent?: string
|
|
32
|
+
}
|
|
33
|
+
const platform =
|
|
34
|
+
ua.userAgentData?.platform ?? ua.platform ?? ua.userAgent ?? ''
|
|
35
|
+
return /mac|iphone|ipad|ipod/i.test(platform) ? '⌘' : 'Ctrl'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Detected per renderer process. Override via
|
|
39
|
+
/// `AIDiffActionsConfig.modSymbol` when the UI should diverge from the
|
|
40
|
+
/// runtime platform (e.g. always render `⌘` in a macOS-themed product).
|
|
41
|
+
export const DEFAULT_DIFF_ACTIONS_MOD_SYMBOL = detectModSymbol()
|
|
42
|
+
|
|
43
|
+
interface DiffActionsPluginOptions {
|
|
44
|
+
config?: AIDiffActionsConfig
|
|
45
|
+
/// The enter-key icon is owned by the top-level `AIFeatureConfig` so a
|
|
46
|
+
/// single override applies to both the instruction tooltip and this
|
|
47
|
+
/// panel's shortcut chip.
|
|
48
|
+
enterKeyIcon?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveDiffActionsConfig(
|
|
52
|
+
options: DiffActionsPluginOptions
|
|
53
|
+
): ResolvedDiffActionsConfig {
|
|
54
|
+
const { config, enterKeyIcon } = options
|
|
55
|
+
return {
|
|
56
|
+
retryLabel: config?.retryLabel ?? DEFAULT_DIFF_ACTIONS_RETRY_LABEL,
|
|
57
|
+
rejectAllLabel:
|
|
58
|
+
config?.rejectAllLabel ?? DEFAULT_DIFF_ACTIONS_REJECT_ALL_LABEL,
|
|
59
|
+
acceptAllLabel:
|
|
60
|
+
config?.acceptAllLabel ?? DEFAULT_DIFF_ACTIONS_ACCEPT_ALL_LABEL,
|
|
61
|
+
retryIcon: config?.retryIcon ?? defaultRetryIcon,
|
|
62
|
+
rejectIcon: config?.rejectIcon ?? defaultRejectIcon,
|
|
63
|
+
acceptIcon: config?.acceptIcon ?? defaultAcceptIcon,
|
|
64
|
+
enterKeyIcon: enterKeyIcon ?? defaultEnterKeyIcon,
|
|
65
|
+
modSymbol: config?.modSymbol ?? DEFAULT_DIFF_ACTIONS_MOD_SYMBOL,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function diffActionsPanelPlugin(options: DiffActionsPluginOptions = {}) {
|
|
70
|
+
const resolved = resolveDiffActionsConfig(options)
|
|
71
|
+
|
|
72
|
+
return $prose((ctx) => {
|
|
73
|
+
return new Plugin({
|
|
74
|
+
key: diffActionsPanelKey,
|
|
75
|
+
view(view) {
|
|
76
|
+
return new DiffActionsPanelView(ctx, view, resolved)
|
|
77
|
+
},
|
|
78
|
+
props: {
|
|
79
|
+
handleKeyDown(view, event) {
|
|
80
|
+
if (event.key !== 'Enter') return false
|
|
81
|
+
if (!(event.metaKey || event.ctrlKey)) return false
|
|
82
|
+
if (!diffPluginKey.getState(view.state)?.active) return false
|
|
83
|
+
// Only intercept Mod-Enter for diffs this AI session started.
|
|
84
|
+
// Manual `startDiffReviewCmd` flows shouldn't have their key
|
|
85
|
+
// bindings mutated just because the AI feature is loaded.
|
|
86
|
+
if (!ctx.get(aiSessionCtx.key).diffOwnedByAI) return false
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
const commands = ctx.get(commandsCtx)
|
|
89
|
+
commands.call(acceptAllDiffsCmd.key)
|
|
90
|
+
return true
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Ctx } from '@milkdown/kit/ctx'
|
|
2
|
+
import type { Node } from '@milkdown/kit/prose/model'
|
|
3
|
+
import type { PluginView } from '@milkdown/kit/prose/state'
|
|
4
|
+
import type { EditorView } from '@milkdown/kit/prose/view'
|
|
5
|
+
|
|
6
|
+
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core'
|
|
7
|
+
import {
|
|
8
|
+
acceptAllDiffsCmd,
|
|
9
|
+
clearDiffReviewCmd,
|
|
10
|
+
diffPluginKey,
|
|
11
|
+
} from '@milkdown/kit/plugin/diff'
|
|
12
|
+
import { TextSelection } from '@milkdown/kit/prose/state'
|
|
13
|
+
import DOMPurify from 'dompurify'
|
|
14
|
+
|
|
15
|
+
import { aiSessionCtx, runAICmd } from '../commands'
|
|
16
|
+
|
|
17
|
+
const PANEL_CLASS = 'milkdown-ai-diff-actions'
|
|
18
|
+
|
|
19
|
+
/// Icons in `ResolvedDiffActionsConfig` are user-supplied SVG strings,
|
|
20
|
+
/// so route them through DOMPurify before assigning to `innerHTML` —
|
|
21
|
+
/// matches the sanitization the shared `Icon` component already does
|
|
22
|
+
/// for Vue-rendered icons.
|
|
23
|
+
function setSanitizedIcon(host: HTMLElement, svg: string): void {
|
|
24
|
+
host.innerHTML = DOMPurify.sanitize(svg.trim())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/// Fully resolved diff actions config — every field has a value, so the
|
|
28
|
+
/// view doesn't have to know about defaults.
|
|
29
|
+
export interface ResolvedDiffActionsConfig {
|
|
30
|
+
retryLabel: string
|
|
31
|
+
rejectAllLabel: string
|
|
32
|
+
acceptAllLabel: string
|
|
33
|
+
retryIcon: string
|
|
34
|
+
rejectIcon: string
|
|
35
|
+
acceptIcon: string
|
|
36
|
+
enterKeyIcon: string
|
|
37
|
+
modSymbol: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createIcon(svg: string): HTMLElement {
|
|
41
|
+
const span = document.createElement('span')
|
|
42
|
+
span.className = `${PANEL_CLASS}-icon`
|
|
43
|
+
setSanitizedIcon(span, svg)
|
|
44
|
+
return span
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class DiffActionsPanelView implements PluginView {
|
|
48
|
+
readonly #panel: HTMLElement
|
|
49
|
+
readonly #host: HTMLElement
|
|
50
|
+
readonly #retryBtn: HTMLButtonElement
|
|
51
|
+
readonly #config: ResolvedDiffActionsConfig
|
|
52
|
+
#visible = false
|
|
53
|
+
/// Tracks the diff plugin's `active` flag across transactions so we
|
|
54
|
+
/// can detect false→true / true→false edges independently of whether
|
|
55
|
+
/// the panel is actually being shown.
|
|
56
|
+
#diffActive = false
|
|
57
|
+
/// Doc snapshot at the moment diff review activated. If the live doc
|
|
58
|
+
/// drifts from this snapshot the user has accepted some per-change
|
|
59
|
+
/// diffs and the stored `lastFrom`/`lastTo` no longer point at the
|
|
60
|
+
/// original range — Retry is unsafe at that point.
|
|
61
|
+
#diffStartDoc: Node | null = null
|
|
62
|
+
/// Whether the active diff review came from this AI session's
|
|
63
|
+
/// streaming hand-off (vs being started manually via
|
|
64
|
+
/// `startDiffReviewCmd`). Captured at the false→true transition.
|
|
65
|
+
/// The panel only renders when this is true so it doesn't take over
|
|
66
|
+
/// non-AI diff flows that exist independently of the AI feature.
|
|
67
|
+
#ownedByAI = false
|
|
68
|
+
|
|
69
|
+
constructor(
|
|
70
|
+
readonly ctx: Ctx,
|
|
71
|
+
view: EditorView,
|
|
72
|
+
config: ResolvedDiffActionsConfig
|
|
73
|
+
) {
|
|
74
|
+
this.#config = config
|
|
75
|
+
this.#host = this.#findHost(view)
|
|
76
|
+
|
|
77
|
+
const panel = document.createElement('div')
|
|
78
|
+
panel.className = PANEL_CLASS
|
|
79
|
+
panel.dataset.show = 'false'
|
|
80
|
+
|
|
81
|
+
this.#retryBtn = this.#makeButton(
|
|
82
|
+
'retry',
|
|
83
|
+
config.retryIcon,
|
|
84
|
+
config.retryLabel,
|
|
85
|
+
this.#retry
|
|
86
|
+
)
|
|
87
|
+
panel.appendChild(this.#retryBtn)
|
|
88
|
+
panel.appendChild(
|
|
89
|
+
this.#makeButton(
|
|
90
|
+
'reject',
|
|
91
|
+
config.rejectIcon,
|
|
92
|
+
config.rejectAllLabel,
|
|
93
|
+
this.#rejectAll
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
const acceptBtn = this.#makeButton(
|
|
97
|
+
'accept',
|
|
98
|
+
config.acceptIcon,
|
|
99
|
+
config.acceptAllLabel,
|
|
100
|
+
this.#acceptAll
|
|
101
|
+
)
|
|
102
|
+
acceptBtn.appendChild(this.#makeShortcutChip())
|
|
103
|
+
panel.appendChild(acceptBtn)
|
|
104
|
+
|
|
105
|
+
this.#panel = panel
|
|
106
|
+
this.#host.appendChild(panel)
|
|
107
|
+
this.update(view)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#findHost(view: EditorView): HTMLElement {
|
|
111
|
+
return (view.dom.closest('.milkdown') as HTMLElement) ?? document.body
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#makeButton(
|
|
115
|
+
variant: 'retry' | 'reject' | 'accept',
|
|
116
|
+
icon: string,
|
|
117
|
+
label: string,
|
|
118
|
+
onClick: () => void
|
|
119
|
+
): HTMLButtonElement {
|
|
120
|
+
const btn = document.createElement('button')
|
|
121
|
+
btn.type = 'button'
|
|
122
|
+
btn.className = `${PANEL_CLASS}-btn ${PANEL_CLASS}-btn-${variant}`
|
|
123
|
+
btn.appendChild(createIcon(icon))
|
|
124
|
+
const text = document.createElement('span')
|
|
125
|
+
text.textContent = label
|
|
126
|
+
btn.appendChild(text)
|
|
127
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault())
|
|
128
|
+
btn.addEventListener('click', (e) => {
|
|
129
|
+
e.preventDefault()
|
|
130
|
+
e.stopPropagation()
|
|
131
|
+
onClick()
|
|
132
|
+
})
|
|
133
|
+
return btn
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#makeShortcutChip(): HTMLElement {
|
|
137
|
+
const shortcut = document.createElement('span')
|
|
138
|
+
shortcut.className = `${PANEL_CLASS}-shortcut`
|
|
139
|
+
const cmd = document.createElement('span')
|
|
140
|
+
cmd.textContent = this.#config.modSymbol
|
|
141
|
+
const enter = document.createElement('span')
|
|
142
|
+
enter.className = `${PANEL_CLASS}-shortcut-icon`
|
|
143
|
+
setSanitizedIcon(enter, this.#config.enterKeyIcon)
|
|
144
|
+
shortcut.append(cmd, enter)
|
|
145
|
+
return shortcut
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#retry = (): void => {
|
|
149
|
+
const session = this.ctx.get(aiSessionCtx.key)
|
|
150
|
+
if (!session.lastInstruction) return
|
|
151
|
+
// Refuse to retry when the active diff didn't originate from this
|
|
152
|
+
// AI session, or once the user has accepted any individual diff —
|
|
153
|
+
// `clearDiffReviewCmd` only exits diff mode, it doesn't roll back
|
|
154
|
+
// accepted changes, so the stored range would point at shifted text.
|
|
155
|
+
if (!this.#ownedByAI || !this.#canRetry()) return
|
|
156
|
+
|
|
157
|
+
const commands = this.ctx.get(commandsCtx)
|
|
158
|
+
commands.call(clearDiffReviewCmd.key)
|
|
159
|
+
const editorView = this.ctx.get(editorViewCtx)
|
|
160
|
+
const { doc } = editorView.state
|
|
161
|
+
const from = Math.min(Math.max(session.lastFrom, 0), doc.content.size)
|
|
162
|
+
const to = Math.min(Math.max(session.lastTo, 0), doc.content.size)
|
|
163
|
+
editorView.dispatch(
|
|
164
|
+
editorView.state.tr.setSelection(
|
|
165
|
+
TextSelection.create(editorView.state.doc, from, to)
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
commands.call(runAICmd.key, {
|
|
169
|
+
instruction: session.lastInstruction,
|
|
170
|
+
label: session.lastLabel,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#canRetry(): boolean {
|
|
175
|
+
if (!this.#diffStartDoc) return false
|
|
176
|
+
const editorView = this.ctx.get(editorViewCtx)
|
|
177
|
+
return editorView.state.doc.eq(this.#diffStartDoc)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#rejectAll = (): void => {
|
|
181
|
+
this.ctx.get(commandsCtx).call(clearDiffReviewCmd.key)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#acceptAll = (): void => {
|
|
185
|
+
this.ctx.get(commandsCtx).call(acceptAllDiffsCmd.key)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
update(view: EditorView): void {
|
|
189
|
+
const diffActive = !!diffPluginKey.getState(view.state)?.active
|
|
190
|
+
|
|
191
|
+
if (diffActive !== this.#diffActive) {
|
|
192
|
+
this.#diffActive = diffActive
|
|
193
|
+
if (diffActive) {
|
|
194
|
+
// false → true. Capture ownership now; from this point on any
|
|
195
|
+
// doc drift means the user has accepted individual diffs.
|
|
196
|
+
const session = this.ctx.get(aiSessionCtx.key)
|
|
197
|
+
this.#ownedByAI = session.diffOwnedByAI
|
|
198
|
+
this.#diffStartDoc = view.state.doc
|
|
199
|
+
} else {
|
|
200
|
+
// true → false. Clear local state and the session-level flag so
|
|
201
|
+
// a subsequent manual diff doesn't pick up stale ownership.
|
|
202
|
+
this.#ownedByAI = false
|
|
203
|
+
this.#diffStartDoc = null
|
|
204
|
+
const session = this.ctx.get(aiSessionCtx.key)
|
|
205
|
+
if (session.diffOwnedByAI) {
|
|
206
|
+
this.ctx.set(aiSessionCtx.key, {
|
|
207
|
+
...session,
|
|
208
|
+
diffOwnedByAI: false,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Render only for AI-owned diff reviews. Non-AI diffs (started via
|
|
215
|
+
// `startDiffReviewCmd` directly) are left to whatever UX the host
|
|
216
|
+
// had in place before this feature shipped.
|
|
217
|
+
const shouldShow = diffActive && this.#ownedByAI
|
|
218
|
+
if (shouldShow !== this.#visible) {
|
|
219
|
+
this.#visible = shouldShow
|
|
220
|
+
this.#panel.dataset.show = shouldShow ? 'true' : 'false'
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (shouldShow) {
|
|
224
|
+
// Re-evaluate Retry every transaction so the button disables the
|
|
225
|
+
// moment a per-change accept shifts the doc out of its initial
|
|
226
|
+
// form.
|
|
227
|
+
const session = this.ctx.get(aiSessionCtx.key)
|
|
228
|
+
const docUntouched =
|
|
229
|
+
!!this.#diffStartDoc && view.state.doc.eq(this.#diffStartDoc)
|
|
230
|
+
this.#retryBtn.disabled = !session.lastInstruction || !docUntouched
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
destroy(): void {
|
|
235
|
+
this.#panel.remove()
|
|
236
|
+
}
|
|
237
|
+
}
|