fix: review-bot findings on the provider-hosted shell
- Keep ConversationSession mounted for blank sessions (chrome-less) so the draft-persistence mirror stays bound in the hero; hero typing reaches the chat store again. - Restore the baselines-ready gate in AppFrame: empty boot snapshots no longer flash the New Workspace hero before either baseline lands. - Commit ordinary sends through the machine (send-committed event + Shell.commitSend): undo can no longer resurrect already-sent content on the default-sink path. - Give the production InputMachine a real wall clock so the typing-run merge window actually expires. - Coalesce concurrent connectWorkspace creates per workspace: the summary has no cwd until the host frame lands, so a second New Session inside that window minted a duplicate hidden blank session.
This commit is contained in:
@@ -250,6 +250,8 @@ export type InputEvent =
|
||||
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
|
||||
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
|
||||
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
|
||||
/** An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — undo must not resurrect sent content (mirrors the command submit-settled success arm). */
|
||||
| { readonly type: 'send-committed' }
|
||||
| { readonly type: 'release' }
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,7 +71,9 @@ export class SessionInputShell implements SessionInput {
|
||||
submit: (mode) => { this.submit(mode) },
|
||||
}
|
||||
|
||||
private readonly core = new InputMachine()
|
||||
// Real wall clock: the typing-run merge window must actually expire in
|
||||
// production (the machine's no-clock default is a constant for pure tests).
|
||||
private readonly core = new InputMachine({ now: () => Date.now() })
|
||||
private noticeSeq = 0
|
||||
private lastDraft = ''
|
||||
private disposed = false
|
||||
@@ -95,6 +97,15 @@ export class SessionInputShell implements SessionInput {
|
||||
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the draft as a successful-send commit: no undo unit is recorded and
|
||||
* the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
|
||||
* (the command path gets the same discipline from submit-settled success).
|
||||
*/
|
||||
commitSend(): void {
|
||||
this.run(this.core.dispatch({ type: 'send-committed' }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a newline at the selection as one machine transaction (the
|
||||
* execCommand path is gone — a second undo history would fork).
|
||||
|
||||
@@ -116,7 +116,8 @@ export class InputHub implements InputService {
|
||||
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
shell?.setDraft('')
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend()
|
||||
void session.prompt([{ type: 'text', text }], mode).then(
|
||||
(result) => {
|
||||
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
|
||||
@@ -167,6 +167,7 @@ export class InputMachine {
|
||||
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
|
||||
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
|
||||
case 'submit-settled': return this.onSubmitSettled(ev)
|
||||
case 'send-committed': return this.onSendCommitted()
|
||||
case 'release': return this.onRelease()
|
||||
default: return unreachable(ev)
|
||||
}
|
||||
@@ -542,6 +543,19 @@ export class InputMachine {
|
||||
return [{ type: 'notice', level: 'error', text }]
|
||||
}
|
||||
|
||||
/** Ordinary send accepted: clear as a commit (no undo unit; sent content
|
||||
* must not be resurrectable — same discipline as submit-settled success). */
|
||||
private onSendCommitted(): InputEffect[] {
|
||||
this.claim = undefined
|
||||
this.occurrences = []
|
||||
this.adopt('')
|
||||
this.log = []
|
||||
this.redoStack = []
|
||||
this.typingRun = undefined
|
||||
this.paste = undefined
|
||||
return []
|
||||
}
|
||||
|
||||
private onRelease(): InputEffect[] {
|
||||
if (this.inflight !== undefined) {
|
||||
this.inflight.controller.abort()
|
||||
|
||||
@@ -77,7 +77,11 @@ export function ConversationRoot({
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
|
||||
{!hero && renderSlot('conversation.session', {})}
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||
{renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
|
||||
Reference in New Issue
Block a user