Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 157 additions & 57 deletions docs/design/unified-tool-design.md

Large diffs are not rendered by default.

58 changes: 14 additions & 44 deletions src/main/kotlin/com/github/codeplangui/ChatService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,9 @@ class ChatService(private val project: Project) : Disposable {
private val pendingApprovals = ConcurrentHashMap<String, CompletableFuture<Boolean>>()
private val pendingApprovalCommands = ConcurrentHashMap<String, String>()

// Tracks which msgIds have had notifyStart sent to the frontend
// When tools are enabled, notifyStart is deferred until the final response round
// so ExecutionCards appear before the assistant bubble
private val bridgeNotifiedStart = mutableSetOf<String>()
// NOTE: bridgeNotifiedStart removed in Phase 2 — notifyStart is now sent
// unconditionally at the start of each streaming round, and round_end replaces
// the old remove_message hack for discarding intermediate tokens.

fun attachBridge(handler: BridgeHandler) {
bridgeHandler = handler
Expand Down Expand Up @@ -178,12 +177,7 @@ class ChatService(private val project: Project) : Disposable {
tools = if (commandExecutionEnabled) listOf(runCommandToolDefinition()) else null
)

// When tools are enabled, defer notifyStart so ExecutionCards appear before the assistant bubble.
// The bubble is created only on the final response round (no tool calls).
if (!commandExecutionEnabled) {
bridgeHandler?.notifyStart(msgId)
bridgeNotifiedStart.add(msgId)
}
// notifyStart is now sent unconditionally in startStreamingRound() (Phase 2).
startStreamingRound(msgId, request, toolsEnabled = commandExecutionEnabled)
}

Expand All @@ -194,7 +188,6 @@ class ChatService(private val project: Project) : Disposable {
val msgId = activeMessageId
activeMessageId = null
if (wasStreaming && msgId != null) {
bridgeNotifiedStart.remove(msgId)
publishStatus()
bridgeHandler?.notifyEnd(msgId)
}
Expand All @@ -206,7 +199,6 @@ class ChatService(private val project: Project) : Disposable {
activeMessageId = null
truncationHandler.reset()
resetToolCallState()
bridgeNotifiedStart.clear()
session = ChatSession()
pendingApprovals.values.forEach { it.complete(false) }
pendingApprovals.clear()
Expand Down Expand Up @@ -324,10 +316,9 @@ class ChatService(private val project: Project) : Disposable {
resetToolCallState()
responseBuffer.clear()

// Do NOT create the assistant bubble here — the next round might produce
// more tool calls. The bubble is created lazily in onToken and removed
// in onFinishReason("tool_calls") if tool calls follow, ensuring all
// execution cards appear before the final streaming bubble.
// The next round's startStreamingRound() will send notifyStart which
// the frontend's groupReducer handles by reusing the existing assistant
// group (still streaming). Intermediate tokens are discarded via round_end.
sendMessageInternal(msgId)
}

Expand Down Expand Up @@ -399,7 +390,6 @@ $selection
activeStream?.cancel()
activeStream = null
activeMessageId = null
bridgeNotifiedStart.remove(msgId)
resetToolCallState()
publishStatus()
bridgeHandler?.notifyStructuredError(BridgeErrorPayload(
Expand All @@ -409,34 +399,22 @@ $selection
}

private fun startStreamingRound(msgId: String, request: okhttp3.Request, toolsEnabled: Boolean) {
// Phase 2: notifyStart sent unconditionally — the frontend's groupReducer
// handles continuation rounds by reusing the existing assistant group.
bridgeHandler?.notifyStart(msgId)

val responseBuffer = StringBuilder()
scope.launch {
val stream = client.streamChat(
request = request,
onToken = { token ->
if (activeMessageId == msgId) {
responseBuffer.append(token)
// Lazily create the assistant bubble on first token.
// If this round ends up producing tool_calls, onFinishReason will
// remove the bubble so execution cards stay in front.
if (msgId !in bridgeNotifiedStart) {
bridgeHandler?.notifyStart(msgId)
bridgeNotifiedStart.add(msgId)
}
bridgeHandler?.notifyToken(token)
}
},
onEnd = {
if (activeMessageId == msgId && !truncationHandler.isPendingContinuation) {
// If the bubble hasn't been started yet (no tool calls in this round),
// start it now and flush the buffered content.
if (msgId !in bridgeNotifiedStart) {
bridgeHandler?.notifyStart(msgId)
bridgeNotifiedStart.add(msgId)
if (responseBuffer.isNotEmpty()) {
bridgeHandler?.notifyToken(responseBuffer.toString())
}
}
logger.info("[CodePlanGUI Approval] model round completed msgId=$msgId")
session.add(Message(
role = MessageRole.ASSISTANT,
Expand All @@ -447,7 +425,6 @@ $selection
persistSession()
activeStream = null
activeMessageId = null
bridgeNotifiedStart.remove(msgId)
publishStatus()
bridgeHandler?.notifyEnd(msgId)
}
Expand All @@ -457,7 +434,6 @@ $selection
logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message")
activeStream = null
activeMessageId = null
bridgeNotifiedStart.remove(msgId)
publishStatus()
bridgeHandler?.notifyStructuredError(classifyStreamError(message))
}
Expand All @@ -469,14 +445,9 @@ $selection
},
onFinishReason = { reason ->
if (toolsEnabled && reason == "tool_calls" && activeMessageId == msgId) {
// Remove the assistant bubble if it was already created by the
// lazy init in onToken. This guarantees that execution cards
// (pushed during handleToolCallComplete) always appear before
// the final assistant bubble.
if (msgId in bridgeNotifiedStart) {
bridgeHandler?.notifyRemoveMessage(msgId)
bridgeNotifiedStart.remove(msgId)
}
// Phase 2: tell the frontend to discard intermediate tokens from
// this round, keeping only execution cards.
bridgeHandler?.notifyRoundEnd(msgId)
val capturedBuffer = responseBuffer
scope.launch { handleToolCallComplete(msgId, capturedBuffer) }
}
Expand Down Expand Up @@ -725,7 +696,6 @@ $selection
pendingApprovals.values.forEach { it.complete(false) }
pendingApprovals.clear()
pendingApprovalCommands.clear()
bridgeNotifiedStart.clear()
scope.cancel()
}

Expand Down
120 changes: 60 additions & 60 deletions src/main/resources/webview/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "tsc -p tsconfig.test.json && node --test test/sendState.test.mjs test/statusState.test.mjs test/composerState.test.mjs test/contextState.test.mjs test/executionCard.test.mjs test/executionStatus.test.mjs test/eventReducer.test.mjs",
"test": "tsc -p tsconfig.test.json && node --test test/sendState.test.mjs test/statusState.test.mjs test/composerState.test.mjs test/contextState.test.mjs test/executionCard.test.mjs test/executionStatus.test.mjs test/eventReducer.test.mjs test/groupReducer.test.mjs",
"postbuild": "node scripts/copy-dist.mjs",
"preview": "vite preview"
},
Expand Down
51 changes: 29 additions & 22 deletions webview/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { BorderOutlined, SendOutlined } from '@ant-design/icons'
import { Button, ConfigProvider, Switch, Tooltip, Typography, theme as antdTheme } from 'antd'
import { v4 as uuidv4 } from 'uuid'
import { AssistantGroup } from './components/AssistantGroup'
import { ApprovalDialog } from './components/ApprovalDialog'
import { ErrorBanner } from './components/ErrorBanner'
import { MessageBubble } from './components/MessageBubble'
import { ProviderBar } from './components/ProviderBar'
import { getComposerReadiness } from './composerState'
import { getContextToggleMeta } from './contextState'
import { stringifyExecutionResultPayload } from './executionStatus'
import { AppState, eventReducer } from './eventReducer'
import { GroupState, groupReducer } from './groupReducer'
import { useBridge } from './hooks/useBridge'
import { prepareSendPayload } from './sendState'
import { BridgeStatus } from './types/bridge'
import './App.css'

const initialAppState: AppState = {
messages: [],
const initialAppState: GroupState = {
groups: [],
isLoading: false,
error: null,
status: {
Expand All @@ -31,16 +31,17 @@ const initialAppState: AppState = {
approvalCommand: '',
approvalDescription: '',
continuationInfo: null,
currentRoundTextIndex: null,
}

export default function App() {
const [appState, setAppState] = useState<AppState>(initialAppState)
const [appState, setAppState] = useState<GroupState>(initialAppState)
const [inputText, setInputText] = useState('')
const isComposingRef = useRef(false)
const [includeContext, setIncludeContext] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)

const { messages, isLoading, error, status, themeMode, approvalOpen, approvalRequestId, approvalCommand, approvalDescription, continuationInfo } = appState
const { groups, isLoading, error, status, themeMode, approvalOpen, approvalRequestId, approvalCommand, approvalDescription, continuationInfo } = appState

// Apply theme class to document root
useEffect(() => {
Expand All @@ -54,7 +55,7 @@ export default function App() {

useEffect(() => {
scrollToBottom()
}, [messages])
}, [groups])

const emitFrontendDebugLog = useCallback((message: string) => {
window.__bridge?.debugLog(message)
Expand All @@ -70,7 +71,7 @@ export default function App() {
emitFrontendDebugLog(`[approval-ui] received execution status requestId=${payload.requestId} status=${payload.status} result=${rawResult.slice(0, 240)}`)
}

setAppState(prev => eventReducer(prev, type, payload))
setAppState(prev => groupReducer(prev, type, payload))
}, [emitFrontendDebugLog])

const handleApprovalAllow = useCallback((addToWhitelist: boolean) => {
Expand Down Expand Up @@ -119,7 +120,7 @@ export default function App() {
const userMsgId = uuidv4()
setAppState(prev => ({
...prev,
messages: [...prev.messages, { id: userMsgId, role: 'user' as const, content: payload.text }],
groups: [...prev.groups, { type: 'human' as const, id: userMsgId, message: { id: userMsgId, content: payload.text } }],
}))
setInputText('')
window.__bridge?.sendMessage(payload.text, includeContext)
Expand All @@ -135,9 +136,10 @@ export default function App() {
const handleNewChat = () => {
setAppState(prev => ({
...prev,
messages: [],
groups: [],
error: null,
isLoading: false,
currentRoundTextIndex: null,
}))
window.__bridge?.newChat()
}
Expand Down Expand Up @@ -195,7 +197,7 @@ export default function App() {
{error && <ErrorBanner error={error} onClose={() => setAppState(prev => ({ ...prev, error: null }))} />}

<div className="messages-area">
{messages.length === 0 && (
{groups.length === 0 && (
<div className="empty-state">
<div className="empty-card">
<div className="empty-icon">✦</div>
Expand All @@ -216,23 +218,28 @@ export default function App() {
</div>
)}

{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}

{(continuationInfo) && (
{groups.map(group => {
if (group.type === 'human') {
return (
<div key={group.id} className="message-row message-row-user">
<div className="message-bubble message-bubble-user">
<Typography.Text>{group.message.content}</Typography.Text>
</div>
</div>
)
}
return <AssistantGroup key={group.id} group={group} />
})}

{isLoading && !groups.some(g =>
g.type === 'assistant' && g.children.some(c => c.kind === 'text' && c.isStreaming)
) && (
<div className="continuation-indicator">
<span className="continuation-spinner" />
{continuationInfo && <span className="continuation-text">续写中 {continuationInfo.current}/{continuationInfo.max}</span>}
</div>
)}

{(!continuationInfo && isLoading && !messages.some((m) => m.isStreaming) && messages.some((m) => m.role === 'execution')) && (
<div className="continuation-indicator">
<span className="continuation-spinner" />
</div>
)}

<div ref={messagesEndRef} />
</div>

Expand Down
81 changes: 81 additions & 0 deletions webview/src/components/AssistantGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { memo, useState } from 'react'
import { CheckOutlined, CopyOutlined } from '@ant-design/icons'
import { Button, Typography } from 'antd'
import type { AssistantGroup as AssistantGroupType } from '../groupReducer'
import { AssistantMarkdown } from './AssistantMarkdown'
import { ExecutionCard } from './ExecutionCard'

async function copyText(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through
}
}

const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()

try {
return document.execCommand('copy')
} finally {
document.body.removeChild(textarea)
}
}

function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)

const handleCopy = async () => {
const success = await copyText(text)
if (!success) return

setCopied(true)
setTimeout(() => setCopied(false), 2000)
}

return (
<Button
type="text"
size="small"
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
onClick={handleCopy}
className="bubble-copy-button"
/>
)
}

interface AssistantGroupProps {
group: AssistantGroupType
}

export const AssistantGroup = memo(function AssistantGroup({ group }: AssistantGroupProps) {
return (
<div className="assistant-group">
{group.children.map(child => {
if (child.kind === 'execution') {
return <ExecutionCard key={child.data.requestId} data={child.data} />
}
return (
<div key={child.id} className="message-row message-row-assistant">
<div className="message-bubble message-bubble-assistant">
<div className="assistant-bubble-header">
<span className="assistant-bubble-label">assistant</span>
<CopyButton text={child.content} />
</div>
<AssistantMarkdown content={child.content} />
{child.isStreaming && <span className="stream-cursor" />}
</div>
</div>
)
})}
</div>
)
})
Loading
Loading