mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(ai-sessions): clear dropped record keys and suppress the echo by position
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
624a716a48
commit
6e3b483053
@@ -26,12 +26,7 @@
|
||||
import { fade } from 'svelte/transition'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import {
|
||||
pendingUserAction,
|
||||
pendingUserActionDetail,
|
||||
RUN_PROMPT_ECHO_MAX,
|
||||
type DisplayMessage
|
||||
} from './shared'
|
||||
import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared'
|
||||
import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode'
|
||||
import { PLAN_MODE_MESSAGES } from './planModeMessages'
|
||||
import type { ContextElement } from './context'
|
||||
@@ -557,25 +552,19 @@
|
||||
// of that turn reaches this tab's transcript until it ends, so without this
|
||||
// the spinner has no subject.
|
||||
//
|
||||
// Suppressed once the transcript already carries the message: a tab that
|
||||
// mounted after the driver's opening save reads it from the store, and the
|
||||
// echo beside it would be the same text drawn twice.
|
||||
// Suppressed once this tab's transcript reaches the run's first message: a tab
|
||||
// that mounted after the driver's opening save reads it from the store, and
|
||||
// the echo beside it would be the same message drawn twice.
|
||||
//
|
||||
// Compared whole, and by prefix only where the echo was cut at the source's
|
||||
// ceiling and a prefix is all there is to compare. Matching on prefix
|
||||
// unconditionally would hide the echo whenever a new prompt opens with the
|
||||
// previous turn's text ("Fix" after "Fix the bug") — which is the case the
|
||||
// sender pins by position precisely to keep.
|
||||
// By position, not by text. Whether this tab holds the message is a fact about
|
||||
// how far its transcript goes, and the same prompt sent twice running ("retry",
|
||||
// "continue") is a new message that merely reads like the one above it —
|
||||
// comparing text hides the echo for the whole of that turn.
|
||||
const remoteUserEcho = $derived.by(() => {
|
||||
const echo = aiChatManager.remoteUserMessage
|
||||
if (!echo || !aiChatManager.runHeldElsewhere) return undefined
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role !== 'user') continue
|
||||
const held = messages[i].content.trim()
|
||||
const already = echo.length >= RUN_PROMPT_ECHO_MAX ? held.startsWith(echo) : held === echo
|
||||
return already ? undefined : echo
|
||||
}
|
||||
return echo
|
||||
const at = aiChatManager.remoteUserMessageAt
|
||||
return at !== undefined && messages.length > at ? undefined : echo
|
||||
})
|
||||
|
||||
// Get app context for display when in APP mode
|
||||
|
||||
@@ -595,6 +595,10 @@ export class AIChatManager {
|
||||
// until its turn ends, and this is only what to draw in the meantime. The
|
||||
// runtime clears it as part of the catch-up that brings in the real message.
|
||||
remoteUserMessage = $state<string | undefined>(undefined)
|
||||
// Where that prompt sits in the driving tab's transcript, so this tab can tell
|
||||
// "I already hold this message" from "I hold a different one that reads the
|
||||
// same" without comparing text.
|
||||
remoteUserMessageAt = $state<number | undefined>(undefined)
|
||||
autonomyMode = $state<AIAutonomyMode>(getPersistedAutonomyMode())
|
||||
// Set by AI sessions. Enables the session-only preview tools and gates plan mode, which
|
||||
// needs the preview pane; the global side-panel chat leaves it false. Reactive because
|
||||
|
||||
@@ -659,13 +659,6 @@ export type DisplayMessage =
|
||||
// is paused on the user. Drives the question card's interactivity, the
|
||||
// "waiting for user" indicator, and routing a composer send to the answer —
|
||||
// keep those in sync by going through this single predicate.
|
||||
/** Longest prompt a driving tab echoes to the tabs watching its run, and the
|
||||
* ceiling that keeps the run-status message bounded — it is the one field whose
|
||||
* length a user sets. Lives here rather than beside the sender so the receiver
|
||||
* can tell a truncated echo from a whole one without importing from `sessions`,
|
||||
* which only imports in the other direction. */
|
||||
export const RUN_PROMPT_ECHO_MAX = 2000
|
||||
|
||||
export function isActiveUserQuestion(message: DisplayMessage | undefined): boolean {
|
||||
return Boolean(
|
||||
message &&
|
||||
|
||||
@@ -91,11 +91,7 @@ import type {
|
||||
} from '$lib/components/raw_apps/rawAppDom'
|
||||
import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
pendingUserAction,
|
||||
RUN_PROMPT_ECHO_MAX,
|
||||
type DisplayMessage
|
||||
} from '$lib/components/copilot/chat/shared'
|
||||
import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared'
|
||||
import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs'
|
||||
import {
|
||||
broadcastRunStatus,
|
||||
@@ -1015,6 +1011,10 @@ async function initRuntime(runtime: SessionRuntime, session: Session) {
|
||||
// ticks and is not reaped as a closed tab.
|
||||
const statusTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
/** Longest prompt echoed to the other tabs, and the ceiling that keeps the run
|
||||
* status bounded: it is the one field of that message whose length a user sets. */
|
||||
const RUN_PROMPT_ECHO_MAX = 2000
|
||||
|
||||
/** The prompt this run is working on: the last thing the user said, searching no
|
||||
* further back than `from`. Read off the driver's rendered transcript rather
|
||||
* than the request, so it is the same text the driving tab has on screen. */
|
||||
@@ -1060,6 +1060,7 @@ function postRunStatus(sessionId: string): void {
|
||||
blockedOnUser: pendingUserAction(m.displayMessages) !== undefined,
|
||||
loadingLabel: m.loadingLabel,
|
||||
userMessage: currentRunPrompt(sessionId, m.displayMessages),
|
||||
userMessageAt: runPrompts.get(sessionId)?.from,
|
||||
planModeActive: m.planModeActive
|
||||
})
|
||||
}
|
||||
@@ -1100,6 +1101,7 @@ function applyRunStatus(msg: RunStatusMsg): void {
|
||||
m.loading = msg.loading
|
||||
m.compacting = msg.compacting
|
||||
m.remoteUserMessage = msg.userMessage
|
||||
m.remoteUserMessageAt = msg.userMessageAt
|
||||
m.loadingLabel = msg.blockedOnUser ? 'Waiting for your answer in the other tab' : msg.loadingLabel
|
||||
}
|
||||
|
||||
@@ -1126,6 +1128,7 @@ async function applyTurnEnd(sessionId: string, chatId: string, attempt = 0): Pro
|
||||
m.loadingLabel = undefined
|
||||
m.compacting = false
|
||||
m.remoteUserMessage = undefined
|
||||
m.remoteUserMessageAt = undefined
|
||||
const id = chatId || m.historyManager.getCurrentChatId()
|
||||
if (!id) {
|
||||
caughtUp = true
|
||||
|
||||
@@ -511,36 +511,6 @@ let remoteReadSeq = 0
|
||||
// would roll the record back on the next write. Reading instead always lands on
|
||||
// what the shared store actually holds. In-memory only — re-persisting here
|
||||
// would echo straight back out through the row funnel.
|
||||
/** Take on what another tab stored, into the record object this tab already
|
||||
* holds rather than in place of it.
|
||||
*
|
||||
* Identity matters: the debounced writers capture the object they were called
|
||||
* on (`setSessionDraftPrompt` flushes 400ms later through `persistTouched(s)`),
|
||||
* so swapping the array slot leaves that flush writing a record this list no
|
||||
* longer contains — putting back the state it held before the other tab wrote.
|
||||
*
|
||||
* Preview tabs keep their live overrides. `friendlyLabel` / `friendlyPath` /
|
||||
* `editorNamed` are stamped by the open editor and deliberately not persisted,
|
||||
* so they are absent from every stored row; taking the row's tabs wholesale
|
||||
* would drop a watching tab's breadcrumbs back to raw paths on any write from
|
||||
* the other tab, a seen-watermark bump included. */
|
||||
function adoptRemoteRow(held: Session, row: Session): void {
|
||||
const stamped = new Map((held.previewTabs ?? []).map((t) => [t.id, t]))
|
||||
Object.assign(held, row)
|
||||
if (row.previewTabs) {
|
||||
held.previewTabs = row.previewTabs.map((t) => {
|
||||
const live = stamped.get(t.id)
|
||||
if (!live) return t
|
||||
return {
|
||||
...t,
|
||||
friendlyLabel: live.friendlyLabel,
|
||||
friendlyPath: live.friendlyPath,
|
||||
editorNamed: live.editorNamed
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRemoteSessionPut(id: string): Promise<void> {
|
||||
if (deletedSessionIds.has(id)) return
|
||||
const token = ++remoteReadSeq
|
||||
@@ -571,6 +541,45 @@ async function applyRemoteSessionPut(id: string): Promise<void> {
|
||||
else sessionState.sessions.splice(at, 0, row)
|
||||
}
|
||||
|
||||
/** Take on what another tab stored, into the record object this tab already
|
||||
* holds rather than in place of it.
|
||||
*
|
||||
* Identity matters: the debounced writers capture the object they were called
|
||||
* on (`setSessionDraftPrompt` flushes 400ms later through `persistTouched(s)`),
|
||||
* so swapping the array slot leaves that flush writing a record this list no
|
||||
* longer contains — putting back the state it held before the other tab wrote.
|
||||
*
|
||||
* Preview tabs keep their live overrides. `friendlyLabel` / `friendlyPath` /
|
||||
* `editorNamed` are stamped by the open editor and deliberately not persisted,
|
||||
* so they are absent from every stored row; taking the row's tabs wholesale
|
||||
* would drop a watching tab's breadcrumbs back to raw paths on any write from
|
||||
* the other tab, a seen-watermark bump included. */
|
||||
export function adoptRemoteRow(held: Session, row: Session): void {
|
||||
const stamped = new Map((held.previewTabs ?? []).map((t) => [t.id, t]))
|
||||
// Keys the row no longer carries are removed, not left standing. This file
|
||||
// clears a field by deleting it — see `applyLifecyclePatch`, and the delete of
|
||||
// `archived` an unarchive performs — and `putSessionRow` stores a snapshot, so
|
||||
// a dropped key is how "this is no longer set" arrives. Assigning over the
|
||||
// held object alone keeps the stale value, and this tab's next write to the
|
||||
// record puts it back into the store, undoing what the other tab did.
|
||||
for (const k of Object.keys(held)) {
|
||||
if (!(k in row)) delete (held as Record<string, unknown>)[k]
|
||||
}
|
||||
Object.assign(held, row)
|
||||
if (row.previewTabs) {
|
||||
held.previewTabs = row.previewTabs.map((t) => {
|
||||
const live = stamped.get(t.id)
|
||||
if (!live) return t
|
||||
return {
|
||||
...t,
|
||||
friendlyLabel: live.friendlyLabel,
|
||||
friendlyPath: live.friendlyPath,
|
||||
editorNamed: live.editorNamed
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror of the local delete path: tombstone so this tab's own pending writes
|
||||
// (unread watermark, preview-tab flush) can't resurrect a record another tab
|
||||
// removed, then drop it from the list.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
renameSession,
|
||||
sessionInCurrentFamily,
|
||||
setGeneratedSessionSummary,
|
||||
adoptRemoteRow,
|
||||
setSessionDraftPrompt,
|
||||
sessionState,
|
||||
type Session
|
||||
@@ -488,3 +489,50 @@ describe('createSession — reuses an untouched draft, family-scoped', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('adoptRemoteRow', () => {
|
||||
it('clears what the stored row no longer carries, keeping the object identity', () => {
|
||||
const held = {
|
||||
id: 's1',
|
||||
name: 's1',
|
||||
createdAt: 1,
|
||||
archived: true,
|
||||
archivedByWorkspace: true
|
||||
} as Session
|
||||
// The other tab unarchived: this file clears a field by deleting it, so the
|
||||
// row simply lacks the key. Assigning over the held object would keep the
|
||||
// stale flag, and this tab's next write would put it back in the store.
|
||||
const row = { id: 's1', name: 's1', createdAt: 1 } as Session
|
||||
|
||||
adoptRemoteRow(held, row)
|
||||
|
||||
expect('archived' in held).toBe(false)
|
||||
expect('archivedByWorkspace' in held).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the live editor stamps the store never holds', () => {
|
||||
const held = {
|
||||
id: 's1',
|
||||
name: 's1',
|
||||
createdAt: 1,
|
||||
previewTabs: [
|
||||
{ id: 't1', url: '/u', loc: '/u', friendlyLabel: 'My script', friendlyPath: 'f/a/b' }
|
||||
]
|
||||
} as Session
|
||||
// Stored tabs carry no stamps — they are recomputed on mount — so taking the
|
||||
// row's tabs wholesale drops a watching tab's labels back to raw paths on any
|
||||
// write from the other tab.
|
||||
const row = {
|
||||
id: 's1',
|
||||
name: 's1',
|
||||
createdAt: 1,
|
||||
previewTabs: [{ id: 't1', url: '/u', loc: '/u2' }]
|
||||
} as Session
|
||||
|
||||
adoptRemoteRow(held, row)
|
||||
|
||||
expect(held.previewTabs?.[0].loc).toBe('/u2')
|
||||
expect(held.previewTabs?.[0].friendlyLabel).toBe('My script')
|
||||
expect(held.previewTabs?.[0].friendlyPath).toBe('f/a/b')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,6 +72,12 @@ type RunStatusMsg = {
|
||||
* wire format fixes; this is the one field a user sets the length of, and the
|
||||
* bound is what keeps that true of the message as a whole. */
|
||||
userMessage: string | undefined
|
||||
/** Where this run's first message sits in the driver's transcript. A watching
|
||||
* tab that has read that far already holds the real message and must not draw
|
||||
* the echo beside it; one that has not, must. Carried because the receiver
|
||||
* cannot decide it from the text: the same prompt sent twice running is a new
|
||||
* message, not a duplicate of the one above it. */
|
||||
userMessageAt: number | undefined
|
||||
/** The driver's plan-mode posture. The only autonomy state worth carrying:
|
||||
* every other one is a stored preference each tab keeps its own copy of,
|
||||
* while plan mode is never persisted and so exists nowhere but the driving
|
||||
|
||||
Reference in New Issue
Block a user