Skip to content
Closed
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
6 changes: 5 additions & 1 deletion adapters/telegram/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,11 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<
const text = accumulatedText.get(chatId)
if (text?.trim()) {
try {
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text)
const chunks = splitMessage(text, TELEGRAM_TEXT_LIMIT)
await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, chunks[0]!)
for (let i = 1; i < chunks.length; i++) {
await bot.api.sendMessage(numericChatId, chunks[i]!)
}
} catch { /* ignore */ }
}
placeholders.delete(chatId)
Expand Down
43 changes: 27 additions & 16 deletions desktop/src/components/chat/AskUserQuestion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
const [activeTab, setActiveTab] = useState(0)
const [selections, setSelections] = useState<QuestionSelections>({})
const [freeText, setFreeText] = useState('')
const [freeTexts, setFreeTexts] = useState<Record<number, string>>({})
const [hasSubmitted, setHasSubmitted] = useState(false)
const composingRef = useRef(false)

Expand All @@ -95,11 +95,12 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
.filter((answer): answer is string => typeof answer === 'string' && answer.trim().length > 0)
.join(', ')
}
return freeText.trim() || questions
.map((question, index) => getSelectedAnswer(question, selections[index]))
const textParts = questions.map((_q, i) => freeTexts[i]?.trim()).filter(Boolean)
const selectParts = questions
.map((question, i) => freeTexts[i]?.trim() ? '' : getSelectedAnswer(question, selections[i]))
.filter(Boolean)
.join('; ')
}, [freeText, questions, resultAnswers, selections])
return textParts.join('; ') + (textParts.length > 0 && selectParts.length > 0 ? '; ' : '') + selectParts.join('; ')
}, [freeTexts, questions, resultAnswers, selections])
const submitted = Object.keys(resultAnswers).length > 0 || hasSubmitted

const handleSelect = (qIndex: number, label: string) => {
Expand All @@ -126,25 +127,31 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
}
return { ...prev, [qIndex]: [label] }
})
setFreeText('')
setFreeTexts((prev) => { const next = { ...prev }; delete next[qIndex]; return next })
}

const handleSubmit = () => {
if (submitted) return

const parts: string[] = []
for (let i = 0; i < questions.length; i++) {
const selected = getSelectedAnswer(questions[i]!, selections[i])
if (selected) parts.push(selected)
const text = freeTexts[i]?.trim()
if (text) {
parts.push(text)
} else {
const selected = getSelectedAnswer(questions[i]!, selections[i])
if (selected) parts.push(selected)
}
}
const response = freeText.trim() || parts.join('; ') || ''
const response = parts.join('; ') || ''
if (!response) return

if (!targetSessionId || !pendingRequest) return

const answers = questions.reduce<Record<string, string>>((acc, question, index) => {
if (freeText.trim()) {
acc[question.question] = freeText.trim()
const text = freeTexts[index]?.trim()
if (text) {
acc[question.question] = text
} else {
const selected = getSelectedAnswer(question, selections[index])
if (selected) acc[question.question] = selected
Expand All @@ -162,7 +169,9 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
}

// All questions must be answered (via selection or free text) to enable submit
const allAnswered = freeText.trim().length > 0 || questions.every((_, i) => (selections[i]?.length ?? 0) > 0)
const allAnswered = questions.every((_, i) =>
(freeTexts[i]?.trim()?.length ?? 0) > 0 || (selections[i]?.length ?? 0) > 0
)
const safeActiveTab = Math.min(activeTab, questions.length - 1)
const activeQuestion = questions[safeActiveTab]

Expand Down Expand Up @@ -202,7 +211,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = (selections[i]?.length ?? 0) > 0
const isAnswered = (freeTexts[i]?.trim()?.length ?? 0) > 0 || (selections[i]?.length ?? 0) > 0
const tabLabel = q.header || `Q${i + 1}`
return (
<button
Expand Down Expand Up @@ -292,10 +301,12 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
</label>
<input
type="text"
value={freeText}
value={freeTexts[safeActiveTab] ?? ''}
onChange={(e) => {
setFreeText(e.target.value)
if (e.target.value.trim()) setSelections({})
setFreeTexts((prev) => ({ ...prev, [safeActiveTab]: e.target.value }))
if (e.target.value.trim()) {
setSelections((prev) => { const next = { ...prev }; delete next[safeActiveTab]; return next })
}
}}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
Expand Down
18 changes: 17 additions & 1 deletion desktop/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,31 @@ function getChatSelectionFromContainer(
function ChatSelectionMenu({
selection,
onAdd,
onDismiss,
}: {
selection: ChatSelectionState | null
onAdd: () => void
onDismiss: () => void
}) {
const t = useTranslation()
const ref = useRef<HTMLButtonElement>(null)

useEffect(() => {
if (!selection) return
const handle = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onDismiss()
}
}
document.addEventListener('mousedown', handle)
return () => document.removeEventListener('mousedown', handle)
}, [selection, onDismiss])

if (!selection) return null

return (
<button
ref={ref}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
Expand Down Expand Up @@ -335,7 +351,7 @@ function SelectableChatMessage({
}}
>
{children}
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} onDismiss={() => setSelectionMenu(null)} />
</div>
)
}
Expand Down
20 changes: 16 additions & 4 deletions desktop/src/components/layout/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ export function TabBar() {
setContextMenu(null)
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
for (const tab of otherTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
if (isSessionTab(tab)) {
const state = useChatStore.getState().sessions[tab.sessionId]
if (!state || state.chatState === 'idle') disconnectSession(tab.sessionId)
}
closeTabWithCleanup(tab)
}
}
Expand All @@ -177,7 +180,10 @@ export function TabBar() {
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const leftTabs = tabs.slice(0, idx)
for (const tab of leftTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
if (isSessionTab(tab)) {
const state = useChatStore.getState().sessions[tab.sessionId]
if (!state || state.chatState === 'idle') disconnectSession(tab.sessionId)
}
closeTabWithCleanup(tab)
}
}
Expand All @@ -187,15 +193,21 @@ export function TabBar() {
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const rightTabs = tabs.slice(idx + 1)
for (const tab of rightTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
if (isSessionTab(tab)) {
const state = useChatStore.getState().sessions[tab.sessionId]
if (!state || state.chatState === 'idle') disconnectSession(tab.sessionId)
}
closeTabWithCleanup(tab)
}
}

const handleCloseAll = () => {
setContextMenu(null)
for (const tab of tabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
if (isSessionTab(tab)) {
const state = useChatStore.getState().sessions[tab.sessionId]
if (!state || state.chatState === 'idle') disconnectSession(tab.sessionId)
}
closeTabWithCleanup(tab)
}
}
Expand Down
20 changes: 18 additions & 2 deletions desktop/src/components/workspace/WorkspacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,15 +321,31 @@ function getLineRangeForText(value: string, text: string) {
function FloatingSelectionMenu({
selection,
onAdd,
onDismiss,
}: {
selection: FloatingSelectionMenuState | null
onAdd: () => void
onDismiss: () => void
}) {
const t = useTranslation()
const ref = useRef<HTMLButtonElement>(null)

useEffect(() => {
if (!selection) return
const handle = (event: globalThis.MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
onDismiss()
}
}
document.addEventListener('mousedown', handle)
return () => document.removeEventListener('mousedown', handle)
}, [selection, onDismiss])

if (!selection) return null

return (
<button
ref={ref}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
Expand Down Expand Up @@ -650,7 +666,7 @@ function CodeSurface({
</div>
)}
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} onDismiss={() => setSelectionMenu(null)} />
</div>
)
}
Expand Down Expand Up @@ -704,7 +720,7 @@ function MarkdownSurface({
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
/>
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} onDismiss={() => setSelectionMenu(null)} />
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export function Settings() {
<div className="flex-1 flex overflow-hidden">
{/* Tab navigation */}
<div className="w-[180px] border-r border-[var(--color-border)] py-3 flex-shrink-0 flex flex-col">
<div className="flex-1">
<div className="flex-1 overflow-y-auto">
<TabButton icon="dns" label={t('settings.tab.providers')} active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
<TabButton icon="shield" label={t('settings.tab.permissions')} active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
Expand Down
49 changes: 40 additions & 9 deletions desktop/src/stores/chatStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -924,15 +924,28 @@ export const useChatStore = create<ChatStore>((set, get) => ({
update(() => ({ streamingText: text }))
}
if (session.elapsedTimer) clearInterval(session.elapsedTimer)
update(() => ({
tokenUsage: msg.usage,
chatState: 'idle',
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
elapsedTimer: null,
}))
useTabStore.getState().updateTabStatus(sessionId, 'idle')

const hasRunningBgTasks = hasRunningBackgroundTasks(session.backgroundAgentTasks)
if (hasRunningBgTasks) {
update(() => ({
tokenUsage: msg.usage,
chatState: 'awaiting_agents',
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
elapsedTimer: null,
}))
} else {
update(() => ({
tokenUsage: msg.usage,
chatState: 'idle',
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
elapsedTimer: null,
}))
useTabStore.getState().updateTabStatus(sessionId, 'idle')
}
const notification = wasAgentRunning
? buildAgentCompletionNotification(sessionId, completionMessages, text)
: null
Expand Down Expand Up @@ -1132,6 +1145,19 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}
})
// Awaiting agents → idle once all background tasks finish
const postSession = get().sessions[sessionId]
if (
postSession?.chatState === 'awaiting_agents' &&
!hasRunningBackgroundTasks(postSession.backgroundAgentTasks)
) {
set((s) => ({
sessions: updateSessionIn(s.sessions, sessionId, () => ({
chatState: 'idle' as const,
})),
}))
useTabStore.getState().updateTabStatus(sessionId, 'idle')
}
}
}
break
Expand All @@ -1141,6 +1167,11 @@ export const useChatStore = create<ChatStore>((set, get) => ({
},
}))

function hasRunningBackgroundTasks(tasks: Record<string, BackgroundAgentTask> | undefined): boolean {
if (!tasks) return false
return Object.values(tasks).some((t) => t.status === 'running')
}

function updateOptimisticSessionTitle(sessionId: string, content: string): void {
const title = deriveSessionTitle(content)
if (!title) return
Expand Down
11 changes: 8 additions & 3 deletions desktop/src/stores/providerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,18 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
activateProvider: async (id) => {
await providersApi.activate(id)
await get().fetchProviders()
// 更新默认 provider 时,同步刷新默认 model,避免 settings.json 里残留
// 旧 provider 的 model id 导致默认选择指向不存在的模型。
const provider = get().providers.find((p) => p.id === id)
if (provider) {
const settings = useSettingsStore.getState()
await settings.setModel(provider.models.main)
const currentModelId = settings.currentModel?.id
const modelIds = providerModelIds(provider)
// 只在当前模型不兼容新 provider 时才 fallback 到 main model,
// 避免静默覆盖用户已选模型(#494)。
if (!currentModelId || !modelIds.has(currentModelId)) {
await settings.setModel(provider.models.main)
}
await settings.fetchAll()
refreshConnectedSessionsForProvider(provider, get().activeId)
}
},

Expand Down
2 changes: 1 addition & 1 deletion desktop/src/types/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export type TokenUsage = {
cache_creation_tokens?: number
}

export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending'
export type ChatState = 'idle' | 'thinking' | 'tool_executing' | 'streaming' | 'permission_pending' | 'awaiting_agents'

export type TeamMemberStatus = {
agentId: string
Expand Down
8 changes: 8 additions & 0 deletions src/server/__tests__/conversation-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ describe('ConversationService', () => {
)
expect(env.CC_HAHA_DESKTOP_SERVER_URL).toBe('http://127.0.0.1:3456')
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe('1')
expect(env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN).toBe('1')
})

test('buildChildEnv does not force interrupted-turn resume for non-SDK sessions', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>

expect(env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN).toBeUndefined()
})

test('uses bun entrypoint fallback on Windows dev mode', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/server/__tests__/workspace-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ describe('WorkspaceService', () => {
})
})

it('lists a single directory level with dotfiles excluded and directories first', async () => {
it('lists a single directory level with dotfiles included and directories first', async () => {
const workDir = await makeTempDir('workspace-service-tree-')
const service = new WorkspaceService(async () => workDir)

Expand All @@ -411,6 +411,7 @@ describe('WorkspaceService', () => {
entries: [
{ name: 'a-dir', path: 'a-dir', isDirectory: true },
{ name: 'b-dir', path: 'b-dir', isDirectory: true },
{ name: '.hidden.txt', path: '.hidden.txt', isDirectory: false },
{ name: 'z-file.txt', path: 'z-file.txt', isDirectory: false },
],
})
Expand Down
Loading
Loading