fix(web): the bugs a chat that never closes for a turn exposes
An investigation pass over the client, driven by steering's new shape — a composer that is always live makes adjacent latent bugs visible. The event stream now watches for silence: the claw keeps the connection alive with a comment every fifteen seconds, and three missed ones abort the fetch and reconnect, so a half-open TCP after a laptop sleep no longer reads as 'live' forever. A reconnect clears the attempted-attachment set with the transcript, or an image whose fetch failed during the outage never retries against the recovered stream. Saving a token reloads the identity and accounts the 401 froze: the banner cleared, but the pages stayed empty until a browser reload. A failed POST retracts its optimistic sent row (and its pending echo, so a message the claw did take still renders from the stream's echo); before, a send into an unreachable claw left a phantom row waiting for a reply that could never come. Drafts are per-session now — the composer's text belonged to no session, so a draft typed into one session sent into whichever was selected at Enter. The providers login form distinguishes a landed login (the roster carries its id) from a failed one and no longer closes on an unrelated state refresh. Two cost fixes that read as jank on long turns: the streamed reply's markdown re-parsed the whole text per delta (quadratic) and now re-renders on a short trailing debounce; the attachment-image watcher walked the whole transcript on every streamed chunk and now scans only the items it has not seen. The accounts page drops a stray Rust-ism comment, and the avatar row's meta line admits webp like its accept attribute always did.
This commit is contained in:
1 parent
041240f706
commit
94b82e2636
8 files changed
+160
-30
No files matched your search
+25
-5
@@ -110,10 +110,16 @@ const attemptedAttachments = new Set<string>()
|
||||
// A replayed question's images fetch the same way — the stream carries
|
||||
// their attachment ids, never the bytes; locally sent rows hold their
|
||||
// own object URLs.
|
||||
//
|
||||
// The watch is deep (rows mutate in place as their bytes land), and it
|
||||
// fires on every streamed delta, so the scan covers only the items the
|
||||
// last pass did not see — the transcript only ever appends.
|
||||
let scannedItems = 0
|
||||
|
||||
watch(
|
||||
transcriptItems,
|
||||
(items) => {
|
||||
for (const item of items) {
|
||||
for (const item of items.slice(scannedItems)) {
|
||||
if (item.kind === 'attachment') {
|
||||
const { id, media, url } = item.attachment
|
||||
if (media !== 'image' || url !== null || attemptedAttachments.has(id)) continue
|
||||
@@ -134,6 +140,7 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
scannedItems = items.length
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -435,7 +442,7 @@ async function removeProvider(id: string): Promise<void> {
|
||||
async function send(message: { text: string; images: StagedImage[] }): Promise<void> {
|
||||
// The optimistic row renders the staged previews; the stream's
|
||||
// user_message echo carries the stored ids and dedupes against it.
|
||||
transcript.addSent(
|
||||
const rowId = transcript.addSent(
|
||||
message.text,
|
||||
message.images.map(({ media_type, url, name }) => ({ id: null, media_type, url, name })),
|
||||
)
|
||||
@@ -451,6 +458,9 @@ async function send(message: { text: string; images: StagedImage[] }): Promise<v
|
||||
)
|
||||
apiError.value = null
|
||||
} catch (err) {
|
||||
// The claw never took the message: retract the optimistic row, or
|
||||
// it would sit there forever waiting for a reply that cannot come.
|
||||
transcript.removeSent(rowId)
|
||||
noteApiError(err)
|
||||
} finally {
|
||||
busy.value = false
|
||||
@@ -503,10 +513,14 @@ function saveToken(): void {
|
||||
authRequired.value = false
|
||||
apiError.value = null
|
||||
// The stream's retry loop reads the token fresh each attempt, so it
|
||||
// recovers on its own; the polls below confirm the token works.
|
||||
// recovers on its own; everything the 401 froze reloads now — the
|
||||
// polls confirm the token works, and the identity and accounts pages
|
||||
// were left empty by the failed fetches that raised the banner.
|
||||
void checkHealth()
|
||||
void refreshProviders()
|
||||
void refreshSessions()
|
||||
void refreshIdentity()
|
||||
void refreshAccounts()
|
||||
}
|
||||
|
||||
// --- the one live subscription ---------------------------------------
|
||||
@@ -524,8 +538,14 @@ function resubscribe(): void {
|
||||
streamStatus.value = status
|
||||
// A retry reopens the stream with the claw's replay of the
|
||||
// recorded turns: drop the mirror and let it rebuild, or the
|
||||
// replay would double everything already on screen.
|
||||
if (status === 'reconnecting') transcript.reset()
|
||||
// replay would double everything already on screen. The
|
||||
// attempted-attachment set goes too — a fetch that failed during
|
||||
// the outage must retry against the recovered stream.
|
||||
if (status === 'reconnecting') {
|
||||
transcript.reset()
|
||||
attemptedAttachments.clear()
|
||||
scannedItems = 0
|
||||
}
|
||||
if (status === 'open') void refreshSessions()
|
||||
},
|
||||
onNotice: pushNotice,
|
||||
|
||||
Reference in new issue
Block a user