@tessera-editor/react 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +2006 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
- package/src/BlockMenu.tsx +131 -0
- package/src/CommentPanel.tsx +192 -0
- package/src/EmbedTocViews.tsx +120 -0
- package/src/EmojiMenu.tsx +134 -0
- package/src/EmptyLineToolbar.tsx +215 -0
- package/src/HistoryPanel.tsx +166 -0
- package/src/ImageNodeView.tsx +120 -0
- package/src/Panels.tsx +205 -0
- package/src/SelectionToolbar.tsx +367 -0
- package/src/SlashMenu.tsx +165 -0
- package/src/TableNodeViews.tsx +276 -0
- package/src/Tessera.tsx +315 -0
- package/src/__tests__/tessera.test.tsx +84 -0
- package/src/context.ts +21 -0
- package/src/index.ts +14 -0
- package/src/portal.ts +36 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useContext, useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
diffDocs,
|
|
4
|
+
diffSummary,
|
|
5
|
+
getStorageService,
|
|
6
|
+
} from '@tessera-editor/core'
|
|
7
|
+
import type { DocSnapshot, BlockDiffEntry } from '@tessera-editor/core'
|
|
8
|
+
import { TesseraContext } from './context'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Version history panel (v1.1): snapshots from the injected StorageService,
|
|
12
|
+
* block+word level diff against the current doc, one-click restore.
|
|
13
|
+
*/
|
|
14
|
+
export function HistoryPanel() {
|
|
15
|
+
const { editor, t } = useContext(TesseraContext)!
|
|
16
|
+
const [visible, setVisible] = useState(false)
|
|
17
|
+
const [snapshots, setSnapshots] = useState<DocSnapshot[]>([])
|
|
18
|
+
const [selected, setSelected] = useState<DocSnapshot | null>(null)
|
|
19
|
+
const [entries, setEntries] = useState<BlockDiffEntry[] | null>(null)
|
|
20
|
+
const [summary, setSummary] = useState({ added: 0, removed: 0, changed: 0 })
|
|
21
|
+
|
|
22
|
+
const refresh = async () => {
|
|
23
|
+
const storage = getStorageService(editor)
|
|
24
|
+
if (!storage) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
const list = await storage.listSnapshots()
|
|
28
|
+
setSnapshots([...list].sort((a, b) => b.ts - a.ts))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const open = () => {
|
|
33
|
+
setVisible(true)
|
|
34
|
+
void refresh()
|
|
35
|
+
}
|
|
36
|
+
editor.on('tessera:historyPanel', open)
|
|
37
|
+
const saved = () => void refresh()
|
|
38
|
+
editor.on('tessera:snapshotSaved', saved as never)
|
|
39
|
+
return () => {
|
|
40
|
+
editor.off('tessera:historyPanel', open)
|
|
41
|
+
editor.off('tessera:snapshotSaved', saved as never)
|
|
42
|
+
}
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [editor])
|
|
45
|
+
|
|
46
|
+
if (!visible) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hasStorage = !!getStorageService(editor)
|
|
51
|
+
|
|
52
|
+
const select = (snap: DocSnapshot) => {
|
|
53
|
+
setSelected(snap)
|
|
54
|
+
const result = diffDocs(snap.doc as never, editor.getJSON() as never)
|
|
55
|
+
setEntries(result)
|
|
56
|
+
setSummary(diffSummary(result.filter(e => e.kind !== 'unchanged')))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const restore = (snap: DocSnapshot) => {
|
|
60
|
+
if (!window.confirm(t('historyConfirmRestore'))) {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
editor.commands.setContent(snap.doc as never)
|
|
64
|
+
setVisible(false)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="tessera-history-panel" data-testid="history-panel">
|
|
69
|
+
<div className="tessera-ask-header">
|
|
70
|
+
<span>{t('historyTitle')}</span>
|
|
71
|
+
<div className="tessera-history-actions">
|
|
72
|
+
{hasStorage ? (
|
|
73
|
+
<button type="button" onClick={() => editor.commands.captureSnapshot()}>
|
|
74
|
+
{t('historyCapture')}
|
|
75
|
+
</button>
|
|
76
|
+
) : null}
|
|
77
|
+
<button type="button" onClick={() => setVisible(false)}>
|
|
78
|
+
×
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
{!hasStorage ? (
|
|
83
|
+
<div className="tessera-ask-error">{t('aiRuntimeMissing').replace('AI Runtime', 'StorageService')}</div>
|
|
84
|
+
) : null}
|
|
85
|
+
<div className="tessera-history-body">
|
|
86
|
+
<div className="tessera-history-list">
|
|
87
|
+
{snapshots.length === 0 ? <div className="tessera-toc-empty">{t('historyEmpty')}</div> : null}
|
|
88
|
+
{snapshots.map(snap => (
|
|
89
|
+
<button
|
|
90
|
+
key={snap.id}
|
|
91
|
+
type="button"
|
|
92
|
+
className="tessera-history-item"
|
|
93
|
+
data-selected={selected?.id === snap.id}
|
|
94
|
+
onClick={() => select(snap)}
|
|
95
|
+
>
|
|
96
|
+
<span className="tessera-history-time">{formatTime(snap.ts)}</span>
|
|
97
|
+
{snap.label ? <span className="tessera-history-label">{snap.label}</span> : null}
|
|
98
|
+
<span className="tessera-history-action" role="button" tabIndex={0}
|
|
99
|
+
onClick={e => {
|
|
100
|
+
e.stopPropagation()
|
|
101
|
+
restore(snap)
|
|
102
|
+
}}
|
|
103
|
+
onKeyDown={e => {
|
|
104
|
+
if (e.key === 'Enter') restore(snap)
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{t('historyRestore')}
|
|
108
|
+
</span>
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
{selected ? (
|
|
113
|
+
<div className="tessera-history-diff">
|
|
114
|
+
<div className="tessera-diff-summary">
|
|
115
|
+
+{summary.added} {t('historyDiffAdded')} · −{summary.removed} {t('historyDiffRemoved')} · ~{summary.changed}{' '}
|
|
116
|
+
{t('historyDiffChanged')}
|
|
117
|
+
</div>
|
|
118
|
+
{(entries ?? [])
|
|
119
|
+
.filter(e => e.kind !== 'unchanged')
|
|
120
|
+
.slice(0, 80)
|
|
121
|
+
.map((entry, i) => (
|
|
122
|
+
<div key={entry.id ?? i} className={`tessera-diff-block tessera-diff-block--${entry.kind}`}>
|
|
123
|
+
<span className="tessera-diff-badge">
|
|
124
|
+
{entry.kind === 'added'
|
|
125
|
+
? t('historyDiffAdded')
|
|
126
|
+
: entry.kind === 'removed'
|
|
127
|
+
? t('historyDiffRemoved')
|
|
128
|
+
: t('historyDiffChanged')}
|
|
129
|
+
</span>
|
|
130
|
+
{entry.kind === 'changed' && entry.wordDiff ? (
|
|
131
|
+
<span className="tessera-diff-text">
|
|
132
|
+
{entry.wordDiff.map((part, j) => (
|
|
133
|
+
<span key={j} className={`tessera-diff-part tessera-diff-part--${part.type}`}>
|
|
134
|
+
{part.text}
|
|
135
|
+
</span>
|
|
136
|
+
))}
|
|
137
|
+
</span>
|
|
138
|
+
) : (
|
|
139
|
+
<span className="tessera-diff-text">{blockText(entry)}</span>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
</div>
|
|
144
|
+
) : null}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function blockText(entry: BlockDiffEntry): string {
|
|
151
|
+
const json = entry.after ?? entry.before
|
|
152
|
+
const walk = (node: unknown): string => {
|
|
153
|
+
if (!node || typeof node !== 'object') {
|
|
154
|
+
return ''
|
|
155
|
+
}
|
|
156
|
+
const n = node as { text?: string; content?: unknown[] }
|
|
157
|
+
return (n.text ?? '') + (n.content ?? []).map(walk).join('')
|
|
158
|
+
}
|
|
159
|
+
return walk(json).trim().slice(0, 120)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function formatTime(ts: number): string {
|
|
163
|
+
const d = new Date(ts)
|
|
164
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
165
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
166
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
|
2
|
+
import type { NodeViewProps } from '@tiptap/react'
|
|
3
|
+
import { ImageBlock } from '@tessera-editor/core'
|
|
4
|
+
import { useContext, useEffect, useRef, useState } from 'react'
|
|
5
|
+
import { createPortal } from 'react-dom'
|
|
6
|
+
import { TesseraContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Image NodeView (acceptance §6): 悬停边缘箭头调宽、左/中/全宽对齐。
|
|
10
|
+
* Uploads flow through the injected UploadService (Tessera.tsx handles
|
|
11
|
+
* paste/drop/picker); this view only mutates node attributes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function AiImageNodeView({ node, updateAttributes, selected, decorations }: NodeViewProps) {
|
|
15
|
+
console.log('[imgview] decorations:', Array.isArray(decorations), decorations?.length)
|
|
16
|
+
const galleryAttrs = (() => {
|
|
17
|
+
for (const deco of decorations ?? []) {
|
|
18
|
+
const attrs = (deco as unknown as { type?: { attrs?: Record<string, string> } }).type?.attrs
|
|
19
|
+
if (attrs && 'data-gallery' in attrs) {
|
|
20
|
+
return attrs
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
})()
|
|
25
|
+
const { t } = useContext(TesseraContext)!
|
|
26
|
+
const imgRef = useRef<HTMLImageElement>(null)
|
|
27
|
+
const [preview, setPreview] = useState<string | null>(null)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!preview) return
|
|
31
|
+
const close = (e: KeyboardEvent) => {
|
|
32
|
+
if (e.key === 'Escape') setPreview(null)
|
|
33
|
+
}
|
|
34
|
+
window.addEventListener('keydown', close)
|
|
35
|
+
return () => window.removeEventListener('keydown', close)
|
|
36
|
+
}, [preview])
|
|
37
|
+
const { src, alt, width, align } = node.attrs as {
|
|
38
|
+
src: string
|
|
39
|
+
alt: string | null
|
|
40
|
+
width: number | null
|
|
41
|
+
align: 'left' | 'center' | 'full'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const startResize = (event: React.PointerEvent) => {
|
|
45
|
+
event.preventDefault()
|
|
46
|
+
event.stopPropagation()
|
|
47
|
+
const startX = event.clientX
|
|
48
|
+
const startWidth = imgRef.current?.getBoundingClientRect().width ?? width ?? 400
|
|
49
|
+
const onMove = (e: PointerEvent) => {
|
|
50
|
+
const next = Math.round(Math.max(80, Math.min(startWidth + (e.clientX - startX), 1200)))
|
|
51
|
+
updateAttributes({ width: next })
|
|
52
|
+
}
|
|
53
|
+
const onUp = () => {
|
|
54
|
+
window.removeEventListener('pointermove', onMove)
|
|
55
|
+
window.removeEventListener('pointerup', onUp)
|
|
56
|
+
}
|
|
57
|
+
window.addEventListener('pointermove', onMove)
|
|
58
|
+
window.addEventListener('pointerup', onUp)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!src) {
|
|
62
|
+
return (
|
|
63
|
+
<NodeViewWrapper className="tessera-image tessera-image-empty" data-selected={selected}>
|
|
64
|
+
<span>{t('imageUploadFailed')}</span>
|
|
65
|
+
</NodeViewWrapper>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<NodeViewWrapper
|
|
71
|
+
className="tessera-image"
|
|
72
|
+
data-align={align}
|
|
73
|
+
data-selected={selected}
|
|
74
|
+
data-gallery={galleryAttrs ? 'true' : undefined}
|
|
75
|
+
data-gallery-index={galleryAttrs?.['data-gallery-index']}
|
|
76
|
+
data-gallery-size={galleryAttrs?.['data-gallery-size']}
|
|
77
|
+
>
|
|
78
|
+
<div className="tessera-image-frame" style={width ? { width: `${width}px` } : undefined}>
|
|
79
|
+
<img
|
|
80
|
+
ref={imgRef}
|
|
81
|
+
src={src}
|
|
82
|
+
alt={alt ?? ''}
|
|
83
|
+
draggable={false}
|
|
84
|
+
style={{ cursor: 'zoom-in' }}
|
|
85
|
+
onClick={e => {
|
|
86
|
+
e.stopPropagation()
|
|
87
|
+
setPreview(src)
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
<div className="tessera-image-resize" onPointerDown={startResize} title="↔" />
|
|
91
|
+
</div>
|
|
92
|
+
{preview
|
|
93
|
+
? createPortal(
|
|
94
|
+
<div className="tessera-lightbox" onClick={() => setPreview(null)}>
|
|
95
|
+
<img src={preview} alt={alt ?? ''} />
|
|
96
|
+
</div>,
|
|
97
|
+
document.body,
|
|
98
|
+
)
|
|
99
|
+
: null}
|
|
100
|
+
<div className="tessera-image-align">
|
|
101
|
+
<button type="button" title={t('imageAlignLeft')} data-on={align === 'left'} onClick={() => updateAttributes({ align: 'left' })}>
|
|
102
|
+
⭰
|
|
103
|
+
</button>
|
|
104
|
+
<button type="button" title={t('imageAlignCenter')} data-on={align === 'center'} onClick={() => updateAttributes({ align: 'center' })}>
|
|
105
|
+
⭤
|
|
106
|
+
</button>
|
|
107
|
+
<button type="button" title={t('imageAlignFull')} data-on={align === 'full'} onClick={() => updateAttributes({ align: 'full' })}>
|
|
108
|
+
⭥
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
</NodeViewWrapper>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Binding-level image block: core schema + React NodeView. */
|
|
116
|
+
export const ImageBlockView = ImageBlock.extend({
|
|
117
|
+
addNodeView() {
|
|
118
|
+
return ReactNodeViewRenderer(AiImageNodeView)
|
|
119
|
+
},
|
|
120
|
+
})
|
package/src/Panels.tsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useContext, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { findReplaceKey } from '@tessera-editor/core'
|
|
3
|
+
import type { FindReplaceState } from '@tessera-editor/core'
|
|
4
|
+
import type { SuggestionSession } from '@tessera-editor/ai'
|
|
5
|
+
import { TesseraContext } from './context'
|
|
6
|
+
|
|
7
|
+
/** ⌘F find & replace panel — drives the core findReplace commands. */
|
|
8
|
+
export function FindReplacePanel() {
|
|
9
|
+
const { editor, t } = useContext(TesseraContext)!
|
|
10
|
+
const [visible, setVisible] = useState(false)
|
|
11
|
+
const [query, setQuery] = useState('')
|
|
12
|
+
const [replacement, setReplacement] = useState('')
|
|
13
|
+
const [count, setCount] = useState(0)
|
|
14
|
+
const [active, setActive] = useState(0)
|
|
15
|
+
const queryRef = useRef(query)
|
|
16
|
+
queryRef.current = query
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const open = () => {
|
|
20
|
+
setVisible(true)
|
|
21
|
+
requestAnimationFrame(() => {
|
|
22
|
+
const input = document.querySelector<HTMLInputElement>('.tessera-find-input')
|
|
23
|
+
input?.focus()
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
editor.on('tessera:findPanel', open)
|
|
27
|
+
|
|
28
|
+
const sync = () => {
|
|
29
|
+
const s = findReplaceKey.getState(editor.state) as FindReplaceState | undefined
|
|
30
|
+
if (!s) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
setVisible(s.visible)
|
|
34
|
+
setCount(s.matches.length)
|
|
35
|
+
setActive(s.active)
|
|
36
|
+
if (s.query !== queryRef.current && !s.visible) {
|
|
37
|
+
setQuery(s.query)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
editor.on('transaction', sync)
|
|
41
|
+
return () => {
|
|
42
|
+
editor.off('tessera:findPanel', open)
|
|
43
|
+
editor.off('transaction', sync)
|
|
44
|
+
}
|
|
45
|
+
}, [editor])
|
|
46
|
+
|
|
47
|
+
if (!visible) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const updateQuery = (value: string) => {
|
|
52
|
+
setQuery(value)
|
|
53
|
+
editor.commands.setFindQuery(value)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="tessera-find-panel" data-testid="find-panel">
|
|
58
|
+
<input
|
|
59
|
+
className="tessera-find-input"
|
|
60
|
+
autoFocus
|
|
61
|
+
value={query}
|
|
62
|
+
placeholder={t('findPlaceholder')}
|
|
63
|
+
onChange={e => updateQuery(e.target.value)}
|
|
64
|
+
onKeyDown={e => {
|
|
65
|
+
if (e.key === 'Enter') {
|
|
66
|
+
editor.commands.findNext()
|
|
67
|
+
}
|
|
68
|
+
if (e.key === 'Escape') {
|
|
69
|
+
editor.commands.closeFindPanel()
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
<span className="tessera-find-count">{count > 0 ? `${active + 1}/${count}` : '0'}</span>
|
|
74
|
+
<button type="button" title={t('findPrev')} onClick={() => editor.commands.findPrev()}>
|
|
75
|
+
↑
|
|
76
|
+
</button>
|
|
77
|
+
<button type="button" title={t('findNext')} onClick={() => editor.commands.findNext()}>
|
|
78
|
+
↓
|
|
79
|
+
</button>
|
|
80
|
+
<input
|
|
81
|
+
value={replacement}
|
|
82
|
+
placeholder={t('replacePlaceholder')}
|
|
83
|
+
onChange={e => setReplacement(e.target.value)}
|
|
84
|
+
/>
|
|
85
|
+
<button type="button" onClick={() => editor.commands.replaceCurrent(replacement)}>
|
|
86
|
+
{t('replaceOne')}
|
|
87
|
+
</button>
|
|
88
|
+
<button type="button" onClick={() => editor.commands.replaceAll(replacement)}>
|
|
89
|
+
{t('replaceAll')}
|
|
90
|
+
</button>
|
|
91
|
+
<button type="button" className="tessera-find-close" title={t('findClose')} onClick={() => editor.commands.closeFindPanel()}>
|
|
92
|
+
×
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** AI 问答面板(/ask 唤起):流式回答,不写文档。 */
|
|
99
|
+
export function AskPanel() {
|
|
100
|
+
const { editor, t, ai } = useContext(TesseraContext)!
|
|
101
|
+
const [visible, setVisible] = useState(false)
|
|
102
|
+
const [question, setQuestion] = useState('')
|
|
103
|
+
const [answer, setAnswer] = useState('')
|
|
104
|
+
const [thinking, setThinking] = useState(false)
|
|
105
|
+
const [error, setError] = useState<string | null>(null)
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const open = () => setVisible(true)
|
|
109
|
+
editor.on('tessera:askPanel', open)
|
|
110
|
+
return () => {
|
|
111
|
+
editor.off('tessera:askPanel', open)
|
|
112
|
+
}
|
|
113
|
+
}, [editor])
|
|
114
|
+
|
|
115
|
+
if (!visible) {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ask = async () => {
|
|
120
|
+
if (!ai || !question.trim()) {
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
setThinking(true)
|
|
124
|
+
setAnswer('')
|
|
125
|
+
setError(null)
|
|
126
|
+
try {
|
|
127
|
+
await ai.ask(question, { onToken: token => setAnswer(prev => prev + token) })
|
|
128
|
+
} catch (err) {
|
|
129
|
+
setError(String(err))
|
|
130
|
+
} finally {
|
|
131
|
+
setThinking(false)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className="tessera-ask-panel" data-testid="ask-panel">
|
|
137
|
+
<div className="tessera-ask-header">
|
|
138
|
+
<span>{t('aiTitle')}</span>
|
|
139
|
+
<button type="button" onClick={() => setVisible(false)}>
|
|
140
|
+
×
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
{ai ? null : <div className="tessera-ask-error">{t('aiRuntimeMissing')}</div>}
|
|
144
|
+
<div className="tessera-ask-body">
|
|
145
|
+
{answer ? <div className="tessera-ask-answer">{answer}</div> : null}
|
|
146
|
+
{thinking ? <div className="tessera-ask-thinking">{t('aiThinking')}</div> : null}
|
|
147
|
+
{error ? <div className="tessera-ask-error">{error}</div> : null}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="tessera-ask-input">
|
|
150
|
+
<input
|
|
151
|
+
value={question}
|
|
152
|
+
placeholder={t('aiAskPlaceholder')}
|
|
153
|
+
onChange={e => setQuestion(e.target.value)}
|
|
154
|
+
onKeyDown={e => {
|
|
155
|
+
if (e.key === 'Enter') {
|
|
156
|
+
ask()
|
|
157
|
+
}
|
|
158
|
+
}}
|
|
159
|
+
/>
|
|
160
|
+
<button type="button" disabled={!ai || thinking || !question.trim()} onClick={ask}>
|
|
161
|
+
{t('aiSend')}
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** "Agent 起草、人批准":pending 建议存在时显示审阅条。 */
|
|
169
|
+
export function SuggestionBar({
|
|
170
|
+
session,
|
|
171
|
+
onClear,
|
|
172
|
+
}: {
|
|
173
|
+
session: SuggestionSession | null
|
|
174
|
+
onClear: () => void
|
|
175
|
+
}) {
|
|
176
|
+
const { t } = useContext(TesseraContext)!
|
|
177
|
+
if (!session) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
return (
|
|
181
|
+
<div className="tessera-suggestion-bar" data-testid="suggestion-bar">
|
|
182
|
+
<span className="tessera-suggestion-label">✨ {t('aiStreaming')}</span>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className="tessera-suggestion-accept"
|
|
186
|
+
onClick={() => {
|
|
187
|
+
session.accept()
|
|
188
|
+
onClear()
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{t('aiAccept')}
|
|
192
|
+
</button>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className="tessera-suggestion-reject"
|
|
196
|
+
onClick={() => {
|
|
197
|
+
session.reject()
|
|
198
|
+
onClear()
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{t('aiReject')}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|