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
43 changes: 43 additions & 0 deletions frontend/src/components/AppCanvas/AppCanvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,46 @@
color: var(--muted, #999);
font-family: var(--font, inherit);
}

.canvas-loading__offline-actions {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 2px;
}

.canvas-loading__offline-button {
min-height: 44px;
padding: 10px 16px;
border: 1px solid var(--border, #333);
border-radius: 10px;
background: var(--surface, #1a1a1a);
color: var(--text, #e5e5e5);
font-family: var(--font, inherit);
font-size: 14px;
font-weight: 600;
line-height: 1;
max-width: 100%;
cursor: pointer;
}

.canvas-loading__offline-button--primary {
border-color: var(--accent, #8b6cf7);
background: var(--accent, #8b6cf7);
color: var(--accent-fg, #fff);
}

.canvas-loading__offline-button--secondary {
background: transparent;
}

.canvas-loading__offline-button:active {
transform: scale(0.97);
}

.canvas-loading__offline-button:focus-visible {
outline: 2px solid var(--accent, #8b6cf7);
outline-offset: 2px;
}
20 changes: 14 additions & 6 deletions frontend/src/components/AppCanvas/AppCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -899,16 +899,24 @@ export default function AppCanvas({
? 'Open it when you’re ready, or switch to another app.'
: serviceSurface.error}
</div>
<div>
<button type="button" onClick={retryService}>
<div className="canvas-loading__offline-actions">
<button
type="button"
className="canvas-loading__offline-button canvas-loading__offline-button--primary"
onClick={retryService}
>
{serviceSurface.phase === 'closed'
? `Open ${appName || serviceSurface.slug}` : 'Retry'}
</button>
{serviceSurface.phase === 'error' && (
<button type="button" onClick={() => {
serviceRequestRef.current += 1
setServiceSurface(current => ({ ...current, phase: 'closed' }))
}}>Close</button>
<button
type="button"
className="canvas-loading__offline-button canvas-loading__offline-button--secondary"
onClick={() => {
serviceRequestRef.current += 1
setServiceSurface(current => ({ ...current, phase: 'closed' }))
}}
>Close</button>
)}
</div>
</div>
Expand Down
59 changes: 18 additions & 41 deletions frontend/src/components/ChatView/ChatView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
saveFailedSendAttempt,
sendAttemptIsDurable,
} from './sendAttemptRecovery.js'
import { persistComposerDraft } from './composerDraft.js'
import {
EMPTY_BUILD_PHASE_RAIL,
accumulateBuildPhase,
Expand Down Expand Up @@ -169,27 +170,6 @@ export function deleteChatDraft(chatId) {
clearChatQuestionDrafts(chatId)
}

// Evict the oldest draft: key from sessionStorage so a new draft can land.
// Oldest = smallest numeric suffix after the colon; that's the chat that
// was least recently opened (chats get integer IDs assigned in order).
function evictOldestDraft() {
try {
const draftKeys = []
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i)
if (key?.startsWith('draft:')) draftKeys.push(key)
}
if (draftKeys.length === 0) return
// Sort ascending by the numeric part; remove the oldest (lowest ID).
draftKeys.sort((a, b) => {
const na = parseInt(a.slice(6), 10) || 0
const nb = parseInt(b.slice(6), 10) || 0
return na - nb
})
sessionStorage.removeItem(draftKeys[0])
} catch { /* ignore */ }
}

function tailResumableBlock(messages) {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].hidden) continue
Expand Down Expand Up @@ -358,7 +338,14 @@ export default function ChatView({
if (!initialComposerRef.current) {
initialComposerRef.current = readInitialComposer(chatId)
}
const [input, setInput] = useState(() => initialComposerRef.current.input)
const [input, setInputState] = useState(() => initialComposerRef.current.input)
function setComposerInput(nextInput) {
// Navigation can unmount this component before React flushes passive
// effects. Keep every composer transition durable at the state boundary,
// whether it came from typing, voice, restoration, or send cleanup.
persistComposerDraft(chatId, nextInput)
setInputState(nextInput)
}
const [sendFailure, setSendFailure] = useState(() => (
initialComposerRef.current.failedAttempt
? 'Möbius is checking whether your previous message reached the chat…'
Expand Down Expand Up @@ -1407,7 +1394,7 @@ export default function ChatView({
function handleComposerInputChange(nextInput) {
clearFailedAttempt()
setSendFailure(null)
setInput(nextInput)
setComposerInput(nextInput)
}

function handleComposerAddFiles(fileList) {
Expand All @@ -1426,7 +1413,7 @@ export default function ChatView({
text,
{ focus = false, preserveFailedAttempt = false } = {},
) {
if (preserveFailedAttempt) setInput(text)
if (preserveFailedAttempt) setComposerInput(text)
else handleComposerInputChange(text)
requestAnimationFrame(() => {
const el = inputRef.current
Expand Down Expand Up @@ -1540,21 +1527,11 @@ export default function ChatView({
}

// Persist draft so it survives leaving and re-entering the chat.
// This remains as a safety net for programmatic input changes (restores,
// voice transcription, send cleanup). Direct owner edits are saved
// synchronously in handleComposerInputChange above.
useEffect(() => {
try {
if (input) sessionStorage.setItem(`draft:${chatId}`, input)
else sessionStorage.removeItem(`draft:${chatId}`)
} catch (e) {
// QuotaExceededError: evict the oldest draft: key and retry once so the
// current chat's draft is always fresh.
if (e?.name === 'QuotaExceededError' || e?.code === 22) {
evictOldestDraft()
try {
if (input) sessionStorage.setItem(`draft:${chatId}`, input)
} catch { /* still no room — skip */ }
}
// Otherwise private browsing / storage disabled — silently skip.
}
persistComposerDraft(chatId, input)
}, [input, chatId])

// Auto-size textarea when a draft is restored. Cap matches the
Expand Down Expand Up @@ -1690,7 +1667,7 @@ export default function ChatView({
if (failedAttempt) {
if (sendAttemptIsDurable(failedAttempt, msgs, data.pending_messages)) {
clearFailedAttempt()
setInput('')
setComposerInput('')
clearFiles()
setSendFailure(null)
} else {
Expand Down Expand Up @@ -2021,7 +1998,7 @@ export default function ChatView({
const queuedPinIntent = sendPinIntent
rememberQueuedPinIntent(cid, queuedPinIntent)
inlineSteerPinIntentRef.current = queuedPinIntent
setInput('')
setComposerInput('')
clearComposerFilesForSend()
if (inputRef.current) {
inputRef.current.style.height = 'auto'
Expand Down Expand Up @@ -2221,7 +2198,7 @@ export default function ChatView({
const userMsg = { role: 'user', content: text, ts: Date.now(), cid, optimistic: true }
if (attachments.length > 0) userMsg.attachments = attachments
commitMessages(prev => [...prev, userMsg])
setInput('')
setComposerInput('')
clearComposerFilesForSend()
if (inputRef.current) {
inputRef.current.style.height = 'auto'
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/components/ChatView/__tests__/composerDraft.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'

import { persistComposerDraft } from '../composerDraft.js'

function storageStub(initial = {}) {
const values = new Map(Object.entries(initial))
return {
get length() { return values.size },
key(index) { return [...values.keys()][index] ?? null },
getItem(key) { return values.get(key) ?? null },
setItem(key, value) { values.set(key, String(value)) },
removeItem(key) { values.delete(key) },
}
}

test('persists and clears a chat draft synchronously', () => {
const storage = storageStub()
assert.equal(persistComposerDraft('chat-a', 'unfinished thought', storage), true)
assert.equal(storage.getItem('draft:chat-a'), 'unfinished thought')

assert.equal(persistComposerDraft('chat-a', '', storage), true)
assert.equal(storage.getItem('draft:chat-a'), null)
})

test('evicts one draft and retries when session storage is full', () => {
const storage = storageStub({ 'draft:chat-a': 'older' })
const normalSet = storage.setItem.bind(storage)
let first = true
storage.setItem = (key, value) => {
if (first) {
first = false
const error = new Error('full')
error.name = 'QuotaExceededError'
throw error
}
normalSet(key, value)
}

assert.equal(persistComposerDraft('chat-b', 'latest', storage), true)
assert.equal(storage.getItem('draft:chat-a'), null)
assert.equal(storage.getItem('draft:chat-b'), 'latest')
})

test('the composer state boundary saves before scheduling React state', () => {
const source = readFileSync(new URL('../ChatView.jsx', import.meta.url), 'utf8')
const start = source.indexOf('function setComposerInput(nextInput)')
const end = source.indexOf('\n }', start)
const body = source.slice(start, end)

const save = body.indexOf('persistComposerDraft(chatId, nextInput)')
const render = body.indexOf('setInputState(nextInput)')
assert.ok(save >= 0, 'all composer changes must be persisted directly')
assert.ok(render > save, 'draft persistence must happen before navigation can unmount React')
})
Loading