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
41 changes: 34 additions & 7 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,14 @@ let bootstrapAbortController = null
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
let forceBootstrapRepair = false
let connectionConfigCache = null
let connectionConfigCacheMtime = null
const hermesLog = []
const previewWatchers = new Map()
let previewShortcutActive = false
let desktopLogBuffer = ''
let desktopLogFlushTimer = null
let desktopLogFlushPromise = Promise.resolve()
let nativeThemeListenerInstalled = false
let bootProgressState = {
error: null,
fakeMode: BOOT_FAKE_MODE,
Expand Down Expand Up @@ -1101,9 +1103,17 @@ function readDesktopUpdateConfig() {
}
}

// Atomic file write: temp + rename (atomic on all platforms). Prevents
// partial writes on crash/power loss that corrupt JSON config files.
function writeFileAtomic(targetPath, data, encoding) {
const tmp = targetPath + '.tmp'
fs.writeFileSync(tmp, data, encoding)
fs.renameSync(tmp, targetPath)
}

function writeDesktopUpdateConfig(config) {
fs.mkdirSync(path.dirname(DESKTOP_UPDATE_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
writeFileAtomic(DESKTOP_UPDATE_CONFIG_PATH, JSON.stringify(config, null, 2))
}

// Match the backend's source resolution but bias toward a real git checkout.
Expand Down Expand Up @@ -1628,7 +1638,7 @@ function writeBootstrapMarker(payload) {
completedAt: new Date().toISOString(),
desktopVersion: app.getVersion()
}
fs.writeFileSync(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
writeFileAtomic(BOOTSTRAP_COMPLETE_MARKER, JSON.stringify(merged, null, 2) + '\n', 'utf8')
return merged
}

Expand Down Expand Up @@ -2094,6 +2104,7 @@ function fetchJson(url, token, options = {}) {
},
res => {
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
Expand Down Expand Up @@ -2433,6 +2444,7 @@ async function resourceBufferFromUrl(rawUrl) {
return
}
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
res.on('end', () => {
resolve({
Expand Down Expand Up @@ -3135,7 +3147,17 @@ function decryptDesktopSecret(secret) {
}

function readDesktopConnectionConfig() {
if (connectionConfigCache) {
// Check if file changed on disk since last read (e.g. modified by another
// process or an external tool). Our own writes update the cache inline
// via writeDesktopConnectionConfig, but external changes would be missed.
let mtime = null
try {
mtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
} catch {
mtime = null
}

if (connectionConfigCache && connectionConfigCacheMtime === mtime) {
return connectionConfigCache
}

Expand All @@ -3156,14 +3178,16 @@ function readDesktopConnectionConfig() {
}

connectionConfigCache = config
connectionConfigCacheMtime = mtime

return config
}

function writeDesktopConnectionConfig(config) {
fs.mkdirSync(path.dirname(DESKTOP_CONNECTION_CONFIG_PATH), { recursive: true })
fs.writeFileSync(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
writeFileAtomic(DESKTOP_CONNECTION_CONFIG_PATH, JSON.stringify(config, null, 2))
connectionConfigCache = config
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
}

function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
Expand Down Expand Up @@ -3491,9 +3515,12 @@ function createWindow() {
}

if (!IS_MAC) {
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
})
}
}

mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
Expand Down
28 changes: 24 additions & 4 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export function ChatBar({
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
const [focusRequestId, setFocusRequestId] = useState(0)
const dragDepthRef = useRef(0)
const composingRef = useRef(false) // true during IME composition (CJK input)
const lastSpokenIdRef = useRef<string | null>(null)

const narrow = useMediaQuery('(max-width: 30rem)')
Expand Down Expand Up @@ -476,6 +477,13 @@ export function ChatBar({
}, [trigger])

const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
// During IME composition the DOM contains uncommitted preedit text
// mixed with real content. Skip state writes — compositionend will
// deliver the finalized text via a clean input event.
if (composingRef.current) {
return
}

const editor = event.currentTarget

if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
Expand Down Expand Up @@ -576,9 +584,11 @@ export function ChatBar({

const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// IME composition: Enter confirms composed text, not a message submission.
// Without this guard, pressing Enter to finalise a Korean/Japanese/Chinese
// IME preedit fires submitDraft() and splits the message mid-word.
if (event.nativeEvent.isComposing) {
// We check both composingRef (set by compositionstart/compositionend, robust
// across browsers) and nativeEvent.isComposing (Chromium fallback). Without
// this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME
// preedit fires submitDraft() and splits the message mid-word.
if (composingRef.current || event.nativeEvent.isComposing) {
return
}

Expand Down Expand Up @@ -971,7 +981,8 @@ export function ChatBar({
const submitted = draft
triggerHaptic('submit')
clearDraft()
void onSubmit(submitted)
clearComposerAttachments()
void onSubmit(submitted, { attachments })
}

focusInput()
Expand Down Expand Up @@ -1110,6 +1121,12 @@ export function ChatBar({
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={() => {
composingRef.current = false
}}
onCompositionStart={() => {
composingRef.current = true
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
onFocus={() => markActiveComposer('main')}
Expand Down Expand Up @@ -1158,6 +1175,9 @@ export function ChatBar({
onDrop={handleDrop}
onSubmit={e => {
e.preventDefault()
if (composingRef.current) {
return
}
submitDraft()
}}
ref={composerRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,12 @@ function useThreadScrollAnchor({
return
}
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
pinToBottom()
// Defer to rAF so that browser scroll/wheel events from the current
// frame are processed first. Without this deferral, a trackpad
// scroll-up during streaming can race with this effect: the wheel
// event hasn't fired yet so stickyBottomRef is still true, and the
// immediate pinToBottom() would snap the viewport back to bottom
// against the user's intent.
requestAnimationFrame(() => {
if (stickyBottomRef.current) {
pinToBottom()
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,9 @@ const ThinkingDisclosure: FC<{
observer.observe(content)

return () => observer.disconnect()
}, [isPreview])
// Re-run when the disclosure toggles so the observer attaches to the new
// DOM after expand/collapse (refs are conditionally rendered on `open`).
}, [isPreview, open])

return (
<div
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@

# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"copii.list@gmail.com": "stremtec",
"prostoandrei9@gmail.com": "vladkvlchk",
"116314616+ThyFriendlyFox@users.noreply.github.com": "ThyFriendlyFox",
"liliangjya@gmail.com": "truenorth-lj",
Expand Down
Loading