From bb20f68865287048455764c7644a71580f9bd55b Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 9 Jun 2026 21:26:12 +0200 Subject: [PATCH 01/20] Implement GUI conversation editing --- internal/auth/auth_test.go | 4 + internal/chat/service.go | 113 +++++++-- internal/chat/service_test.go | 206 ++++++++++++++- internal/storage/sqlite.go | 61 ++++- internal/storage/sqlite_test.go | 61 +++++ internal/storage/storage.go | 1 + internal/web/assets/app.css | 142 ++++++++++- internal/web/assets/app.js | 400 ++++++++++++++++++++++++++++-- internal/web/server.go | 58 ++++- internal/web/server_test.go | 199 ++++++++++++++- internal/web/templates/index.html | 69 ++++-- 11 files changed, 1195 insertions(+), 119 deletions(-) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 2d022d5..60e1dd3 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -380,6 +380,10 @@ func (s authTestStore) AppendTurn(context.Context, int64, llm.Message, llm.Messa return nil } +func (s authTestStore) ReplaceTailAndAppendTurn(context.Context, int64, int, llm.Message, llm.Message) error { + return nil +} + func (s authTestStore) Close() error { return nil } diff --git a/internal/chat/service.go b/internal/chat/service.go index 62c182a..3a4954b 100644 --- a/internal/chat/service.go +++ b/internal/chat/service.go @@ -12,8 +12,9 @@ import ( ) var ( - ErrEmptyPrompt = errors.New("prompt must not be empty") - ErrTurnInProgress = errors.New("turn already in progress") + ErrEmptyPrompt = errors.New("prompt must not be empty") + ErrInvalidReplaceFrom = errors.New("replace_from must point to a user message or the end of the conversation") + ErrTurnInProgress = errors.New("turn already in progress") ) type Service struct { @@ -32,6 +33,7 @@ func NewPersistentService(client llm.Client, store Store) Service { type Store interface { Messages(context.Context, int64) ([]llm.Message, error) AppendTurn(context.Context, int64, llm.Message, llm.Message) error + ReplaceTailAndAppendTurn(context.Context, int64, int, llm.Message, llm.Message) error } func (s Service) NewSession() *Session { @@ -68,6 +70,7 @@ type SendOptions struct { ReasoningEffort string RenderingInstructions string TelemetryComponent string + ReplaceFrom *int } func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*TurnStream, error) { @@ -98,7 +101,13 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T observability.RecordSpanError(span, ErrTurnInProgress) return nil, ErrTurnInProgress } - request.Messages = append(llm.CloneMessages(s.messages), userMessage.Clone()) + keepMessages, err := replaceFromIndex(s.messages, opts.ReplaceFrom) + if err != nil { + s.mu.Unlock() + observability.RecordSpanError(span, err) + return nil, err + } + request.Messages = append(llm.CloneMessages(s.messages[:keepMessages]), userMessage.Clone()) s.inFlight = true s.mu.Unlock() @@ -112,11 +121,12 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T } return &TurnStream{ - session: s, - stream: stream, - userMessage: userMessage, - ctx: ctx, - startedAt: startedAt, + session: s, + stream: stream, + userMessage: userMessage, + keepMessages: keepMessages, + ctx: ctx, + startedAt: startedAt, }, nil } @@ -126,12 +136,41 @@ func (s *Session) Messages() []llm.Message { return llm.CloneMessages(s.messages) } +func (s *Session) ValidateReplaceFrom(replaceFrom *int) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := replaceFromIndex(s.messages, replaceFrom) + return err +} + +func (s *Session) CommitStopped(ctx context.Context, prompt string, opts SendOptions) error { + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return ErrEmptyPrompt + } + userMessage := llm.NewTextMessage(llm.RoleUser, prompt) + + s.mu.Lock() + keepMessages, err := replaceFromIndex(s.messages, opts.ReplaceFrom) + if err != nil { + s.mu.Unlock() + return err + } + if err := s.replaceTailLocked(ctx, keepMessages, userMessage, llm.Message{Role: llm.RoleAssistant}); err != nil { + s.mu.Unlock() + return err + } + s.mu.Unlock() + return nil +} + type TurnStream struct { - session *Session - stream llm.Stream - userMessage llm.Message - ctx context.Context - startedAt time.Time + session *Session + stream llm.Stream + userMessage llm.Message + keepMessages int + ctx context.Context + startedAt time.Time assistantParts []llm.Part completed bool @@ -143,7 +182,9 @@ func (s *TurnStream) Next() (llm.Event, error) { event, err := s.stream.Next() if err != nil { s.recordError(err) - s.abort() + if !errors.Is(err, context.Canceled) { + s.abort() + } return llm.Event{}, err } @@ -156,7 +197,7 @@ func (s *TurnStream) Next() (llm.Event, error) { s.mergeCompletedPart(event.Part) case llm.EventCompleted: s.completed = true - if err := s.finalize(); err != nil { + if err := s.finalize(false); err != nil { s.recordError(err) s.abort() return event, err @@ -181,6 +222,10 @@ func (s *TurnStream) Close() error { return s.stream.Close() } +func (s *TurnStream) CommitPartial() error { + return s.finalize(true) +} + func (s *TurnStream) appendDelta(partType llm.PartType, delta string) { if delta == "" { return @@ -230,8 +275,8 @@ func (s *TurnStream) mergeCompletedPart(part llm.Part) { s.assistantParts = append(s.assistantParts, part.Clone()) } -func (s *TurnStream) finalize() error { - if !s.completed || s.finalized { +func (s *TurnStream) finalize(allowIncomplete bool) error { + if (!s.completed && !allowIncomplete) || s.finalized { return nil } @@ -242,14 +287,10 @@ func (s *TurnStream) finalize() error { s.session.mu.Lock() defer s.session.mu.Unlock() - if s.session.store != nil { - if err := s.session.store.AppendTurn(context.Background(), s.session.conversationID, s.userMessage.Clone(), assistant.Clone()); err != nil { - return err - } + if err := s.session.replaceTailLocked(context.Background(), s.keepMessages, s.userMessage, assistant); err != nil { + return err } s.finalized = true - s.session.messages = append(s.session.messages, s.userMessage.Clone(), assistant) - s.session.inFlight = false return nil } @@ -299,6 +340,32 @@ func (s *Session) releaseTurn() { s.inFlight = false } +func replaceFromIndex(messages []llm.Message, replaceFrom *int) (int, error) { + if replaceFrom == nil { + return len(messages), nil + } + keepMessages := *replaceFrom + if keepMessages < 0 || keepMessages > len(messages) { + return 0, ErrInvalidReplaceFrom + } + if keepMessages < len(messages) && messages[keepMessages].Role != llm.RoleUser { + return 0, ErrInvalidReplaceFrom + } + return keepMessages, nil +} + +func (s *Session) replaceTailLocked(ctx context.Context, keepMessages int, userMessage, assistant llm.Message) error { + if s.store != nil { + if err := s.store.ReplaceTailAndAppendTurn(ctx, s.conversationID, keepMessages, userMessage.Clone(), assistant.Clone()); err != nil { + return err + } + } + nextMessages := append(llm.CloneMessages(s.messages[:keepMessages]), userMessage.Clone(), assistant.Clone()) + s.messages = nextMessages + s.inFlight = false + return nil +} + func cloneParts(parts []llm.Part) []llm.Part { if parts == nil { return nil diff --git a/internal/chat/service_test.go b/internal/chat/service_test.go index 8b27a88..b8fd8c8 100644 --- a/internal/chat/service_test.go +++ b/internal/chat/service_test.go @@ -136,6 +136,97 @@ func TestSessionSendIncludesPriorTurnsAndReasoning(t *testing.T) { } } +func TestSessionSendCanReplaceTailFromUserMessage(t *testing.T) { + client := dummy.NewClient( + dummy.Turn{TextChunks: []string{"first answer"}}, + dummy.Turn{TextChunks: []string{"second answer"}}, + dummy.Turn{TextChunks: []string{"replacement answer"}}, + ) + session := NewService(client).NewSession() + + first, err := session.Send(context.Background(), "first", SendOptions{}) + if err != nil { + t.Fatalf("first Send() error = %v, want nil", err) + } + collectEvents(t, first) + second, err := session.Send(context.Background(), "second", SendOptions{}) + if err != nil { + t.Fatalf("second Send() error = %v, want nil", err) + } + collectEvents(t, second) + + replaceFrom := 2 + replacement, err := session.Send(context.Background(), "edited second", SendOptions{ReplaceFrom: &replaceFrom}) + if err != nil { + t.Fatalf("replacement Send() error = %v, want nil", err) + } + collectEvents(t, replacement) + + requests := client.Requests() + if len(requests) != 3 { + t.Fatalf("request count = %d, want 3", len(requests)) + } + if got := requests[2].Messages; len(got) != 3 || got[0].Text() != "first" || got[1].Text() != "first answer" || got[2].Text() != "edited second" { + t.Fatalf("replacement request messages = %#v, want first turn plus edited prompt", requests[2].Messages) + } + messages := session.Messages() + if len(messages) != 4 { + t.Fatalf("stored message count = %d, want 4", len(messages)) + } + if messages[0].Text() != "first" || messages[1].Text() != "first answer" || messages[2].Text() != "edited second" || messages[3].Text() != "replacement answer" { + t.Fatalf("stored messages = %#v, want tail replaced by edited turn", messages) + } +} + +func TestSessionSendRejectsReplaceFromAssistantMessage(t *testing.T) { + session := NewService(dummy.NewClient(dummy.Turn{TextChunks: []string{"answer"}})).NewSession() + stream, err := session.Send(context.Background(), "first", SendOptions{}) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + collectEvents(t, stream) + + replaceFrom := 1 + _, err = session.Send(context.Background(), "bad edit", SendOptions{ReplaceFrom: &replaceFrom}) + if !errors.Is(err, ErrInvalidReplaceFrom) { + t.Fatalf("Send() error = %v, want ErrInvalidReplaceFrom", err) + } +} + +func TestSessionValidateReplaceFromAndCommitStopped(t *testing.T) { + session := NewService(dummy.NewClient()).NewSession() + if err := session.ValidateReplaceFrom(nil); err != nil { + t.Fatalf("ValidateReplaceFrom(nil) error = %v, want nil", err) + } + if err := session.CommitStopped(context.Background(), " first ", SendOptions{}); err != nil { + t.Fatalf("CommitStopped append error = %v, want nil", err) + } + messages := session.Messages() + if len(messages) != 2 || messages[0].Text() != "first" || messages[1].Role != llm.RoleAssistant { + t.Fatalf("messages after stopped append = %#v, want user plus empty assistant", messages) + } + + replaceFrom := 0 + if err := session.ValidateReplaceFrom(&replaceFrom); err != nil { + t.Fatalf("ValidateReplaceFrom(user index) error = %v, want nil", err) + } + if err := session.CommitStopped(context.Background(), "edited first", SendOptions{ReplaceFrom: &replaceFrom}); err != nil { + t.Fatalf("CommitStopped replace error = %v, want nil", err) + } + messages = session.Messages() + if len(messages) != 2 || messages[0].Text() != "edited first" { + t.Fatalf("messages after stopped replacement = %#v, want edited stopped turn", messages) + } + + assistantIndex := 1 + if err := session.ValidateReplaceFrom(&assistantIndex); !errors.Is(err, ErrInvalidReplaceFrom) { + t.Fatalf("ValidateReplaceFrom(assistant index) error = %v, want ErrInvalidReplaceFrom", err) + } + if err := session.CommitStopped(context.Background(), " ", SendOptions{}); !errors.Is(err, ErrEmptyPrompt) { + t.Fatalf("CommitStopped(empty) error = %v, want ErrEmptyPrompt", err) + } +} + func TestPersistentSessionLoadsHistoryAndAppendsCompletedTurn(t *testing.T) { store := &chatStore{ messages: []llm.Message{ @@ -165,11 +256,42 @@ func TestPersistentSessionLoadsHistoryAndAppendsCompletedTurn(t *testing.T) { if requests[0].Messages[0].Text() != "stored prompt" || requests[0].Messages[1].Text() != "stored answer" || requests[0].Messages[2].Text() != "fresh prompt" { t.Fatalf("request messages = %#v, want stored history before fresh prompt", requests[0].Messages) } - if len(store.appended) != 1 { - t.Fatalf("append count = %d, want 1", len(store.appended)) + if len(store.replaced) != 1 { + t.Fatalf("replace count = %d, want 1", len(store.replaced)) } - if store.appended[0].conversationID != 42 || store.appended[0].user.Text() != "fresh prompt" || store.appended[0].assistant.Text() != "fresh answer" { - t.Fatalf("appended turn = %#v, want completed fresh turn in conversation 42", store.appended[0]) + if store.replaced[0].conversationID != 42 || store.replaced[0].keepMessages != 2 || store.replaced[0].user.Text() != "fresh prompt" || store.replaced[0].assistant.Text() != "fresh answer" { + t.Fatalf("replaced turn = %#v, want completed fresh turn after stored history in conversation 42", store.replaced[0]) + } +} + +func TestPersistentSessionReplaceTailPersistsEditedTurn(t *testing.T) { + store := &chatStore{ + messages: []llm.Message{ + llm.NewTextMessage(llm.RoleUser, "stored first"), + llm.NewTextMessage(llm.RoleAssistant, "stored first answer"), + llm.NewTextMessage(llm.RoleUser, "stored second"), + llm.NewTextMessage(llm.RoleAssistant, "stored second answer"), + }, + } + client := dummy.NewClient(dummy.Turn{TextChunks: []string{"edited answer"}}) + session, err := NewPersistentService(client, store).NewPersistedSession(context.Background(), 42) + if err != nil { + t.Fatalf("NewPersistedSession error = %v, want nil", err) + } + + replaceFrom := 2 + stream, err := session.Send(context.Background(), "edited second", SendOptions{ReplaceFrom: &replaceFrom}) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + collectEvents(t, stream) + + if len(store.replaced) != 1 { + t.Fatalf("replace count = %d, want 1", len(store.replaced)) + } + replaced := store.replaced[0] + if replaced.conversationID != 42 || replaced.keepMessages != 2 || replaced.user.Text() != "edited second" || replaced.assistant.Text() != "edited answer" { + t.Fatalf("replaced turn = %#v, want replacement at message index 2", replaced) } } @@ -191,7 +313,7 @@ func TestPersistentSessionReturnsHistoryLoadFailure(t *testing.T) { } } -func TestPersistentSessionDoesNotAppendFailedOrAbortedTurn(t *testing.T) { +func TestPersistentSessionDoesNotAppendFailedOrClosedTurn(t *testing.T) { t.Run("failed stream", func(t *testing.T) { store := &chatStore{} session, err := NewPersistentService(failingClient{}, store).NewPersistedSession(context.Background(), 42) @@ -207,12 +329,12 @@ func TestPersistentSessionDoesNotAppendFailedOrAbortedTurn(t *testing.T) { if err == nil { t.Fatalf("Next() error = nil, want failure") } - if len(store.appended) != 0 { - t.Fatalf("append count = %d, want 0", len(store.appended)) + if len(store.appended) != 0 || len(store.replaced) != 0 { + t.Fatalf("stored turn count = appended %d replaced %d, want 0", len(store.appended), len(store.replaced)) } }) - t.Run("aborted stream", func(t *testing.T) { + t.Run("closed stream without partial commit", func(t *testing.T) { store := &chatStore{} session, err := NewPersistentService(eventClient{events: []llm.Event{{Type: llm.EventTextDelta, Delta: "partial"}}}, store).NewPersistedSession(context.Background(), 42) if err != nil { @@ -225,12 +347,49 @@ func TestPersistentSessionDoesNotAppendFailedOrAbortedTurn(t *testing.T) { if err := stream.Close(); err != nil { t.Fatalf("Close() error = %v, want nil", err) } - if len(store.appended) != 0 { - t.Fatalf("append count = %d, want 0", len(store.appended)) + if len(store.appended) != 0 || len(store.replaced) != 0 { + t.Fatalf("stored turn count = appended %d replaced %d, want 0", len(store.appended), len(store.replaced)) } }) } +func TestSessionCanCommitPartialTurn(t *testing.T) { + session := NewService(eventClient{ + events: []llm.Event{ + {Type: llm.EventReasoningDelta, Delta: "thinking"}, + {Type: llm.EventTextDelta, Delta: "partial"}, + }, + }).NewSession() + + stream, err := session.Send(context.Background(), "hello", SendOptions{}) + if err != nil { + t.Fatalf("Send() error = %v, want nil", err) + } + if event, nextErr := stream.Next(); nextErr != nil || event.Type != llm.EventReasoningDelta { + t.Fatalf("first Next() = %#v, %v; want reasoning delta", event, nextErr) + } + if event, nextErr := stream.Next(); nextErr != nil || event.Type != llm.EventTextDelta { + t.Fatalf("second Next() = %#v, %v; want text delta", event, nextErr) + } + if err := stream.CommitPartial(); err != nil { + t.Fatalf("CommitPartial() error = %v, want nil", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close() error = %v, want nil", err) + } + + messages := session.Messages() + if len(messages) != 2 { + t.Fatalf("message count = %d, want 2", len(messages)) + } + if messages[0].Text() != "hello" || messages[1].Text() != "partial" { + t.Fatalf("messages = %#v, want committed partial turn", messages) + } + if messages[1].Parts[0].Type != llm.PartReasoning || messages[1].Parts[0].Text != "thinking" { + t.Fatalf("assistant parts = %#v, want partial reasoning and text", messages[1].Parts) + } +} + func TestPersistentSessionReturnsAppendFailureAndDoesNotKeepInFlight(t *testing.T) { store := &chatStore{appendErr: errors.New("append failed")} session, err := NewPersistentService(dummy.NewClient(dummy.Turn{TextChunks: []string{"answer"}}), store).NewPersistedSession(context.Background(), 42) @@ -580,7 +739,7 @@ func TestTurnStreamFinalizeAndClonePartsGuards(t *testing.T) { userMessage: llm.NewTextMessage(llm.RoleUser, "hello"), } - if err := turn.finalize(); err != nil { + if err := turn.finalize(false); err != nil { t.Fatalf("finalize before completion error = %v, want nil", err) } if got := len(session.Messages()); got != 0 { @@ -588,10 +747,10 @@ func TestTurnStreamFinalizeAndClonePartsGuards(t *testing.T) { } turn.completed = true - if err := turn.finalize(); err != nil { + if err := turn.finalize(false); err != nil { t.Fatalf("finalize after completion error = %v, want nil", err) } - if err := turn.finalize(); err != nil { + if err := turn.finalize(false); err != nil { t.Fatalf("second finalize error = %v, want nil", err) } if got := len(session.Messages()); got != 2 { @@ -677,6 +836,7 @@ type chatStore struct { messages []llm.Message messagesErr error appended []appendedTurn + replaced []replacedTurn appendErr error } @@ -686,6 +846,13 @@ type appendedTurn struct { assistant llm.Message } +type replacedTurn struct { + conversationID int64 + keepMessages int + user llm.Message + assistant llm.Message +} + func (s *chatStore) Messages(context.Context, int64) ([]llm.Message, error) { if s.messagesErr != nil { return nil, s.messagesErr @@ -705,6 +872,19 @@ func (s *chatStore) AppendTurn(_ context.Context, conversationID int64, user, as return nil } +func (s *chatStore) ReplaceTailAndAppendTurn(_ context.Context, conversationID int64, keepMessages int, user, assistant llm.Message) error { + if s.appendErr != nil { + return s.appendErr + } + s.replaced = append(s.replaced, replacedTurn{ + conversationID: conversationID, + keepMessages: keepMessages, + user: user.Clone(), + assistant: assistant.Clone(), + }) + return nil +} + func collectEvents(t *testing.T, stream *TurnStream) []llm.Event { t.Helper() defer stream.Close() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 06557ba..95c1c72 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -402,33 +402,76 @@ func (s *SQLite) AppendTurn(ctx context.Context, conversationID int64, userMessa if conversationID <= 0 || userMessage.Role != llm.RoleUser || assistantMessage.Role != llm.RoleAssistant { return ErrInvalidArgument } - userParts, err := json.Marshal(userMessage.Parts) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } - assistantParts, err := json.Marshal(assistantMessage.Parts) - if err != nil { + defer rollback(tx) + + var maxSequence int + if err := tx.QueryRowContext(ctx, ` +SELECT COALESCE(MAX(sequence), 0) +FROM messages +WHERE conversation_id = ? +`, conversationID).Scan(&maxSequence); err != nil { return err } + if err := insertTurnAtSequence(ctx, tx, conversationID, maxSequence+1, userMessage, assistantMessage); err != nil { + return err + } + return tx.Commit() +} + +func (s *SQLite) ReplaceTailAndAppendTurn(ctx context.Context, conversationID int64, keepMessages int, userMessage, assistantMessage llm.Message) error { + if conversationID <= 0 || userMessage.Role != llm.RoleUser || assistantMessage.Role != llm.RoleAssistant { + return ErrInvalidArgument + } + if keepMessages < 0 { + return ErrInvalidArgument + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer rollback(tx) - var maxSequence int64 + var messageCount int if err := tx.QueryRowContext(ctx, ` -SELECT COALESCE(MAX(sequence), 0) +SELECT COUNT(*) FROM messages WHERE conversation_id = ? -`, conversationID).Scan(&maxSequence); err != nil { +`, conversationID).Scan(&messageCount); err != nil { + return err + } + if keepMessages > messageCount { + return ErrInvalidArgument + } + if _, err := tx.ExecContext(ctx, ` +DELETE FROM messages +WHERE conversation_id = ? AND sequence > ? +`, conversationID, keepMessages); err != nil { + return err + } + if err := insertTurnAtSequence(ctx, tx, conversationID, keepMessages+1, userMessage, assistantMessage); err != nil { + return err + } + return tx.Commit() +} + +func insertTurnAtSequence(ctx context.Context, tx *sql.Tx, conversationID int64, firstSequence int, userMessage, assistantMessage llm.Message) error { + userParts, err := json.Marshal(userMessage.Parts) + if err != nil { + return err + } + assistantParts, err := json.Marshal(assistantMessage.Parts) + if err != nil { return err } createdAt := formatTime(time.Now().UTC()) if _, err := tx.ExecContext(ctx, ` INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at) VALUES (?, ?, ?, ?, ?) -`, conversationID, maxSequence+1, userMessage.Role, string(userParts), createdAt); err != nil { +`, conversationID, firstSequence, userMessage.Role, string(userParts), createdAt); err != nil { if sqliteIsConstraint(err) { return ErrInvalidArgument } @@ -437,13 +480,13 @@ VALUES (?, ?, ?, ?, ?) if _, err := tx.ExecContext(ctx, ` INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at) VALUES (?, ?, ?, ?, ?) -`, conversationID, maxSequence+2, assistantMessage.Role, string(assistantParts), createdAt); err != nil { +`, conversationID, firstSequence+1, assistantMessage.Role, string(assistantParts), createdAt); err != nil { if sqliteIsConstraint(err) { return ErrInvalidArgument } return err } - return tx.Commit() + return nil } type sqlExecer interface { diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index ccc7328..5019ff2 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -377,6 +377,39 @@ func TestSQLiteMessagesAreOrderedAndPartsJSONRoundTrips(t *testing.T) { } } +func TestSQLiteReplaceTailAndAppendTurnTruncatesAndAppends(t *testing.T) { + store := newMigratedTestSQLite(t) + ctx := context.Background() + user := createTestUser(t, store, "dina") + conversation, err := store.DefaultConversationForUser(ctx, user.ID) + if err != nil { + t.Fatalf("DefaultConversationForUser error = %v, want nil", err) + } + + if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer one")); err != nil { + t.Fatalf("AppendTurn first error = %v, want nil", err) + } + if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "second"), llm.NewTextMessage(llm.RoleAssistant, "answer two")); err != nil { + t.Fatalf("AppendTurn second error = %v, want nil", err) + } + + err = store.ReplaceTailAndAppendTurn(ctx, conversation.ID, 2, llm.NewTextMessage(llm.RoleUser, "edited second"), llm.NewTextMessage(llm.RoleAssistant, "replacement answer")) + if err != nil { + t.Fatalf("ReplaceTailAndAppendTurn error = %v, want nil", err) + } + + messages, err := store.Messages(ctx, conversation.ID) + if err != nil { + t.Fatalf("Messages error = %v, want nil", err) + } + if len(messages) != 4 { + t.Fatalf("message count = %d, want 4", len(messages)) + } + if messages[0].Text() != "first" || messages[1].Text() != "answer one" || messages[2].Text() != "edited second" || messages[3].Text() != "replacement answer" { + t.Fatalf("messages = %#v, want first turn plus edited replacement turn", messages) + } +} + func TestSQLiteAppendTurnRejectsInvalidInputWithoutPersisting(t *testing.T) { store := newMigratedTestSQLite(t) ctx := context.Background() @@ -404,6 +437,34 @@ func TestSQLiteAppendTurnRejectsInvalidInputWithoutPersisting(t *testing.T) { } } +func TestSQLiteReplaceTailAndAppendTurnRejectsInvalidKeepCount(t *testing.T) { + store := newMigratedTestSQLite(t) + ctx := context.Background() + user := createTestUser(t, store, "edie") + conversation, err := store.DefaultConversationForUser(ctx, user.ID) + if err != nil { + t.Fatalf("DefaultConversationForUser error = %v, want nil", err) + } + if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer")); err != nil { + t.Fatalf("AppendTurn error = %v, want nil", err) + } + + for _, keepMessages := range []int{-1, 3} { + err := store.ReplaceTailAndAppendTurn(ctx, conversation.ID, keepMessages, llm.NewTextMessage(llm.RoleUser, "bad"), llm.NewTextMessage(llm.RoleAssistant, "bad answer")) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("ReplaceTailAndAppendTurn keep=%d error = %v, want ErrInvalidArgument", keepMessages, err) + } + } + + messages, err := store.Messages(ctx, conversation.ID) + if err != nil { + t.Fatalf("Messages error = %v, want nil", err) + } + if len(messages) != 2 || messages[0].Text() != "first" || messages[1].Text() != "answer" { + t.Fatalf("messages after rejected replacements = %#v, want original turn intact", messages) + } +} + func TestSQLiteReadPathDataCorruptionReturnsErrors(t *testing.T) { store := newMigratedTestSQLite(t) ctx := context.Background() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index bd7f073..3755252 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -25,6 +25,7 @@ type Store interface { DefaultConversationForUser(context.Context, int64) (Conversation, error) Messages(context.Context, int64) ([]llm.Message, error) AppendTurn(context.Context, int64, llm.Message, llm.Message) error + ReplaceTailAndAppendTurn(context.Context, int64, int, llm.Message, llm.Message) error Close() error } diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 8cf1994..91c397b 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -829,11 +829,15 @@ button { box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.12); } -.composer { +.composer-dock { margin: 0; padding: 0 0 1rem; } +.composer { + margin: 0; +} + .composer-box { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -844,6 +848,7 @@ button { border-radius: 0.9rem; background: var(--pico-card-background-color); box-shadow: 0 -0.35rem 1.5rem rgba(0, 0, 0, 0.04); + will-change: transform; } .composer-box:focus-within { @@ -891,21 +896,140 @@ button { border-radius: 999px; } -.stop-button { - border-color: var(--pico-del-color); - color: var(--pico-del-color); +.history-button, +.action-button { + border-color: var(--pico-muted-border-color); + background: transparent; + color: var(--pico-muted-color); } -.send-button { - border-color: var(--pico-primary); - background: var(--pico-primary); - color: var(--pico-primary-inverse); +.history-button:hover, +.history-button:focus-visible, +.action-button:hover, +.action-button:focus-visible { + background: var(--pico-muted-border-color); + color: var(--pico-color); +} + +.action-button[data-action-state="stop"], +.action-button[data-action-state="stopping"] { + color: var(--pico-del-color); } .icon-button:disabled { opacity: 0.45; } +.message-edit-slot { + display: none; +} + +.message-user[data-editing="true"] { + padding: 0.35rem; +} + +.message-user[data-editing="true"] > .message-text, +.message-user[data-editing="true"] > .message-actions { + display: none; +} + +.message-user[data-editing="true"] > .message-edit-slot { + display: block; +} + +.message-user .composer { + padding: 0; +} + +.message-user .composer-box { + border-color: color-mix(in srgb, var(--pico-background-color) 35%, transparent); + background: transparent; + box-shadow: none; +} + +.message-user .composer-box:focus-within { + border-color: var(--pico-background-color); + box-shadow: 0 0 0 var(--pico-outline-width) color-mix(in srgb, var(--pico-background-color) 22%, transparent); +} + +.message-user .composer textarea { + color: var(--pico-background-color); +} + +.message-user .composer textarea::placeholder { + color: color-mix(in srgb, var(--pico-background-color) 65%, transparent); +} + +.message-user .composer-status, +.message-user .history-button, +.message-user .action-button { + color: color-mix(in srgb, var(--pico-background-color) 78%, transparent); +} + +.message-user .history-button, +.message-user .action-button { + border-color: color-mix(in srgb, var(--pico-background-color) 30%, transparent); +} + +.message-user .history-button:hover, +.message-user .history-button:focus-visible, +.message-user .action-button:hover, +.message-user .action-button:focus-visible { + background: color-mix(in srgb, var(--pico-background-color) 18%, transparent); + color: var(--pico-background-color); +} + +.message-stopped .message-stopped-note { + margin-top: 0.45rem; + color: var(--pico-muted-color); + font-size: 0.75rem; +} + +.dirty-dialog { + width: min(26rem, calc(100vw - 2rem)); + padding: 0; + border: var(--pico-border-width) solid var(--pico-muted-border-color); + border-radius: 0.5rem; +} + +.dirty-dialog::backdrop { + background: rgba(0, 0, 0, 0.28); +} + +.dirty-dialog-form { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 1rem; +} + +.dirty-dialog h2, +.dirty-dialog p { + margin: 0; +} + +.dirty-dialog h2 { + font-size: 1rem; +} + +.dirty-dialog p { + color: var(--pico-muted-color); + font-size: 0.9rem; +} + +.dirty-dialog-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + +.dirty-dialog-actions button { + width: auto; + margin: 0; + white-space: nowrap; +} + .sr-only { position: absolute; width: 1px; @@ -934,7 +1058,7 @@ button { padding: 0.8rem 0.875rem; } - .composer { + .composer-dock { padding-bottom: 0.75rem; } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index bee3b73..44e04c3 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -3,11 +3,15 @@ const messages = document.getElementById('messages'); const messagesEnd = document.getElementById('messages-end'); const scrollButton = document.getElementById('scroll-bottom'); + const composerDock = document.getElementById('composer-dock'); const form = document.getElementById('chat-form'); const prompt = document.getElementById('prompt'); - const sendButton = document.getElementById('send-button'); - const stopButton = document.getElementById('stop-button'); + const actionButton = document.getElementById('composer-action'); + const actionIcons = actionButton ? actionButton.querySelectorAll('[data-action-icon]') : []; + const undoButton = document.getElementById('undo-button'); + const redoButton = document.getElementById('redo-button'); const composerStatus = document.getElementById('composer-status'); + const dirtyDialog = document.getElementById('dirty-dialog'); const themeToggle = document.querySelector('[data-theme-toggle]'); const themeIcons = themeToggle ? themeToggle.querySelectorAll('[data-theme-icon]') : []; @@ -24,6 +28,11 @@ let abortRequested = false; let creatingTurn = false; let statusIDCounter = 0; + let nextMessageIndex = initialNextMessageIndex(); + let currentEditIndex = null; + let originalPromptValue = ''; + let dockPromptValue = ''; + let pendingNavigation = null; let mermaidInitialized = false; let mermaidCurrentTheme = ''; let mermaidIDCounter = 0; @@ -106,6 +115,55 @@ } } + function messageIndex(article) { + const value = Number.parseInt(article?.dataset.messageIndex || '', 10); + return Number.isFinite(value) ? value : -1; + } + + function initialNextMessageIndex() { + let maxIndex = -1; + messages.querySelectorAll('.message[data-message-index]').forEach(function (article) { + maxIndex = Math.max(maxIndex, messageIndex(article)); + }); + return maxIndex + 1; + } + + function transcriptPosition() { + return currentEditIndex === null ? nextMessageIndex : currentEditIndex; + } + + function userMessages() { + return Array.from(messages.querySelectorAll('.message-user[data-editable-prompt="true"][data-message-index]')) + .sort(function (left, right) { + return messageIndex(left) - messageIndex(right); + }); + } + + function userMessageBefore(index) { + let target = null; + userMessages().forEach(function (article) { + if (messageIndex(article) < index) { + target = article; + } + }); + return target; + } + + function userMessageAfter(index) { + return userMessages().find(function (article) { + return messageIndex(article) > index; + }) || null; + } + + function promptTextForArticle(article) { + const body = article?.querySelector('.message-text'); + return body ? body.textContent || '' : ''; + } + + function hasDirtyPrompt() { + return prompt.value !== originalPromptValue; + } + function isNearBottom() { return messages.scrollHeight - messages.scrollTop - messages.clientHeight < nearBottomThreshold; } @@ -163,6 +221,115 @@ removeMessage(user); } + function articleForEditIndex(index) { + return messages.querySelector(`.message-user[data-editable-prompt="true"][data-message-index="${index}"]`); + } + + function editSlotFor(article) { + let slot = article.querySelector(':scope > .message-edit-slot'); + if (!slot) { + slot = document.createElement('div'); + slot.className = 'message-edit-slot'; + const actions = article.querySelector(':scope > .message-actions'); + article.insertBefore(slot, actions || null); + } + return slot; + } + + function clearEditingArticle() { + const editing = messages.querySelector('.message-user[data-editing="true"]'); + if (editing) { + delete editing.dataset.editing; + } + } + + function animateComposerFrom(firstRect) { + if (!firstRect || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return; + } + const box = form.querySelector('.composer-box'); + if (!box) { + return; + } + const lastRect = form.getBoundingClientRect(); + const dx = firstRect.left - lastRect.left; + const dy = firstRect.top - lastRect.top; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) { + return; + } + box.style.transition = 'none'; + box.style.transform = `translate(${dx}px, ${dy}px)`; + window.requestAnimationFrame(function () { + box.style.transition = 'transform 180ms ease'; + box.style.transform = 'translate(0, 0)'; + }); + box.addEventListener('transitionend', function cleanup(event) { + if (event.propertyName !== 'transform') { + return; + } + box.style.transition = ''; + box.style.transform = ''; + box.removeEventListener('transitionend', cleanup); + }); + } + + function focusPrompt(selection) { + window.requestAnimationFrame(function () { + prompt.focus({ preventScroll: true }); + const position = selection === 'start' ? 0 : prompt.value.length; + prompt.setSelectionRange(position, position); + }); + } + + function scrollComposerIntoView(targetIndex) { + const target = targetIndex === null ? composerDock : articleForEditIndex(targetIndex); + if (!target) { + return; + } + const behavior = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; + target.scrollIntoView({ block: targetIndex === null ? 'nearest' : 'center', behavior }); + } + + function moveComposerTo(targetIndex, selection) { + const firstRect = form.getBoundingClientRect(); + if (currentEditIndex === null) { + dockPromptValue = prompt.value; + } + clearEditingArticle(); + + if (targetIndex === null) { + composerDock.append(form); + currentEditIndex = null; + prompt.value = dockPromptValue; + originalPromptValue = dockPromptValue; + } else { + const article = articleForEditIndex(targetIndex); + if (!article) { + return; + } + article.dataset.editing = 'true'; + editSlotFor(article).append(form); + currentEditIndex = targetIndex; + originalPromptValue = promptTextForArticle(article); + prompt.value = originalPromptValue; + } + + syncPromptHeight(); + updateComposerState(); + animateComposerFrom(firstRect); + scrollComposerIntoView(targetIndex); + focusPrompt(selection); + } + + function moveComposerToDockForSubmit() { + const firstRect = form.getBoundingClientRect(); + clearEditingArticle(); + composerDock.append(form); + currentEditIndex = null; + dockPromptValue = ''; + animateComposerFrom(firstRect); + } + function createMessageActions() { const actions = document.createElement('div'); actions.className = 'message-actions'; @@ -301,6 +468,12 @@ clearEmptyState(); const article = document.createElement('article'); article.className = `message message-${role}`; + const index = options && Number.isInteger(options.messageIndex) ? options.messageIndex : nextMessageIndex; + article.dataset.messageIndex = String(index); + nextMessageIndex = Math.max(nextMessageIndex, index + 1); + if (role === 'user') { + article.dataset.editablePrompt = 'true'; + } if (options && options.streaming) { article.classList.add('message-streaming'); } @@ -318,6 +491,17 @@ return { article, text: messageText }; } + function truncateMessagesFrom(index) { + messages.querySelectorAll('.message[data-message-index]').forEach(function (article) { + if (messageIndex(article) >= index) { + article.remove(); + } + }); + nextMessageIndex = index; + ensureEmptyState(); + updateScrollButton(); + } + function assignMessageIDs(user, assistant, turn) { if (turn.user_message_id) { user.article.id = `message-${turn.user_message_id}`; @@ -359,11 +543,32 @@ function updateComposerState() { const submitting = Boolean(currentTurn) || creatingTurn; - sendButton.disabled = submitting || prompt.value.trim() === ''; - stopButton.disabled = !currentTurn || abortRequested; + if (actionButton) { + const state = currentTurn ? (abortRequested ? 'stopping' : 'stop') : 'send'; + actionButton.dataset.actionState = state; + actionButton.disabled = currentTurn ? abortRequested : creatingTurn || prompt.value.trim() === ''; + actionButton.setAttribute('aria-label', currentTurn ? (abortRequested ? 'Stopping response' : 'Stop response') : 'Send message'); + actionButton.title = currentTurn ? 'Stop response' : 'Send message'; + actionIcons.forEach(function (icon) { + icon.hidden = icon.dataset.actionIcon !== (currentTurn ? 'stop' : 'send'); + }); + } + updateHistoryButtons(submitting); prompt.disabled = submitting; } + function updateHistoryButtons(submitting) { + const busy = submitting || Boolean(dirtyDialog && dirtyDialog.open); + const previous = userMessageBefore(transcriptPosition()); + const canRedo = currentEditIndex !== null; + if (undoButton) { + undoButton.disabled = busy || !previous; + } + if (redoButton) { + redoButton.disabled = busy || !canRedo; + } + } + function closeSource() { if (currentSource) { currentSource.close(); @@ -403,6 +608,29 @@ assistant.text.replaceChildren(error); } + function markTurnStopped(assistant) { + if (!assistant) { + return; + } + assistant.article.classList.remove('message-streaming'); + assistant.article.classList.add('message-stopped'); + completeThinkingStatus(assistant.article); + if (!assistantHasContent(assistant)) { + const status = assistant.article.querySelector('.message-status'); + if (status) { + status.remove(); + } + } + let note = assistant.article.querySelector('.message-stopped-note'); + if (!note) { + note = document.createElement('div'); + note.className = 'message-stopped-note'; + note.textContent = 'Stopped'; + const actions = assistant.article.querySelector('.message-actions'); + assistant.article.insertBefore(note, actions || null); + } + } + function languageFromCode(code) { if (!code) { return 'code'; @@ -636,14 +864,18 @@ }, 1400); } - async function submitPrompt(text) { + async function submitPrompt(text, options) { + const body = { prompt: text }; + if (options && Number.isInteger(options.replaceFrom)) { + body.replace_from = options.replaceFrom; + } const response = await fetch('/chat/turns', { method: 'POST', headers: { 'Content-Type': 'application/json', [csrfHeaderName()]: csrfToken, }, - body: JSON.stringify({ prompt: text }), + body: JSON.stringify(body), }); if (!response.ok) { throw new Error(await response.text()); @@ -679,6 +911,69 @@ } } + function navigateTo(targetIndex, selection) { + moveComposerTo(targetIndex, selection || (targetIndex === null ? 'end' : 'end')); + } + + function continueNavigationAfterDiscard() { + if (!pendingNavigation) { + return; + } + const target = pendingNavigation; + pendingNavigation = null; + prompt.value = originalPromptValue; + if (currentEditIndex === null) { + dockPromptValue = originalPromptValue; + } + navigateTo(target.index, target.selection); + } + + function requestNavigation(targetIndex, selection) { + if (currentTurn || creatingTurn) { + return; + } + if (hasDirtyPrompt()) { + pendingNavigation = { index: targetIndex, selection }; + updateComposerState(); + if (dirtyDialog && typeof dirtyDialog.showModal === 'function') { + dirtyDialog.showModal(); + updateComposerState(); + return; + } + if (window.confirm('Discard unsaved prompt changes?')) { + continueNavigationAfterDiscard(); + } else { + pendingNavigation = null; + } + updateComposerState(); + return; + } + navigateTo(targetIndex, selection); + } + + function navigateBackward() { + const previous = userMessageBefore(transcriptPosition()); + if (previous) { + requestNavigation(messageIndex(previous), 'end'); + } + } + + function navigateForward() { + if (currentEditIndex === null) { + return; + } + const next = userMessageAfter(currentEditIndex); + requestNavigation(next ? messageIndex(next) : null, 'start'); + } + + function caretAtStart() { + return prompt.selectionStart === 0 && prompt.selectionEnd === 0; + } + + function caretAtEnd() { + return prompt.selectionStart === prompt.value.length && prompt.selectionEnd === prompt.value.length; + } + async function abortDisconnectedTurn(turn, user, assistant) { if (currentTurn !== turn) { return; @@ -693,7 +988,7 @@ return; } if (currentTurn === turn) { - discardTurn(user, assistant); + markTurnStopped(assistant); finishTurn('Stream disconnected'); } } @@ -770,7 +1065,7 @@ return; } clearStreamErrorTimer(); - discardTurn(user, assistant); + markTurnStopped(assistant); finishTurn('Response stopped'); }); @@ -818,18 +1113,26 @@ return; } - const user = addMessage('user', text); + const replaceFrom = currentEditIndex; + moveComposerToDockForSubmit(); + if (replaceFrom !== null) { + truncateMessagesFrom(replaceFrom); + } + + const userIndex = replaceFrom === null ? nextMessageIndex : replaceFrom; + const user = addMessage('user', text, { messageIndex: userIndex }); const assistant = addMessage('assistant', '', { streaming: true }); currentUser = user; currentAssistant = assistant; prompt.value = ''; + originalPromptValue = ''; syncPromptHeight(); creatingTurn = true; updateComposerState(); setStatus('Starting response'); try { - const turn = await submitPrompt(text); + const turn = await submitPrompt(text, replaceFrom === null ? null : { replaceFrom }); assignMessageIDs(user, assistant, turn); creatingTurn = false; subscribe(turn, user, assistant); @@ -842,7 +1145,20 @@ }); prompt.addEventListener('keydown', function (event) { - if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey || event.isComposing || event.keyCode === 229) { + if (event.isComposing || event.keyCode === 229) { + return; + } + if (event.key === 'ArrowUp' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && caretAtStart()) { + event.preventDefault(); + navigateBackward(); + return; + } + if (event.key === 'ArrowDown' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && caretAtEnd()) { + event.preventDefault(); + navigateForward(); + return; + } + if (event.key !== 'Enter' || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) { return; } event.preventDefault(); @@ -852,28 +1168,58 @@ }); prompt.addEventListener('input', function () { + if (currentEditIndex === null) { + dockPromptValue = prompt.value; + } syncPromptHeight(); updateComposerState(); }); - stopButton.addEventListener('click', async function () { - if (!currentTurn) { - return; - } - const turn = currentTurn; - try { - await requestAbort(turn); - if (currentTurn === turn) { - discardTurn(currentUser, currentAssistant); - finishTurn('Response stopped'); + if (actionButton) { + actionButton.addEventListener('click', async function () { + if (!currentTurn) { + form.requestSubmit(); + return; } - } catch (error) { - if (currentTurn === turn && currentAssistant) { - markTurnError(currentAssistant, 'The turn could not be stopped.'); - finishTurn('Stop failed'); + const turn = currentTurn; + try { + await requestAbort(turn); + if (currentTurn === turn) { + markTurnStopped(currentAssistant); + finishTurn('Response stopped'); + } + } catch (error) { + if (currentTurn === turn && currentAssistant) { + markTurnError(currentAssistant, 'The turn could not be stopped.'); + finishTurn('Stop failed'); + } } - } - }); + }); + } + + if (undoButton) { + undoButton.addEventListener('click', navigateBackward); + } + + if (redoButton) { + redoButton.addEventListener('click', navigateForward); + } + + if (dirtyDialog) { + dirtyDialog.addEventListener('close', function () { + const action = dirtyDialog.returnValue; + if (action === 'submit') { + pendingNavigation = null; + form.requestSubmit(); + } else if (action === 'discard') { + continueNavigationAfterDiscard(); + } else { + pendingNavigation = null; + } + dirtyDialog.returnValue = ''; + updateComposerState(); + }); + } messages.addEventListener('scroll', updateScrollButton, { passive: true }); diff --git a/internal/web/server.go b/internal/web/server.go index 86961f8..ef1b404 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -182,6 +182,10 @@ func (s *Server) handleCreateTurn(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "empty_prompt", "prompt must not be empty") return } + if err := session.chat.ValidateReplaceFrom(request.ReplaceFrom); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_replace_from", "replace_from must point to a user message or the end of the conversation") + return + } turn, err := newTurnJobWithContext(context.WithoutCancel(r.Context()), prompt) if err != nil { @@ -205,6 +209,7 @@ func (s *Server) handleCreateTurn(w http.ResponseWriter, r *http.Request) { ReasoningEffort: s.reasoningEffort, RenderingInstructions: chat.WebRenderingInstructions(), TelemetryComponent: observability.ComponentWeb, + ReplaceFrom: request.ReplaceFrom, }) writeJSON(w, http.StatusCreated, createTurnResponse{ @@ -687,7 +692,8 @@ func validCSRF(r *http.Request, token string) bool { } type createTurnRequest struct { - Prompt string `json:"prompt"` + Prompt string `json:"prompt"` + ReplaceFrom *int `json:"replace_from,omitempty"` } type createTurnResponse struct { @@ -714,6 +720,7 @@ type authPageData struct { } type viewMessage struct { + Index int Role string Label string Text string @@ -733,6 +740,7 @@ func viewMessages(messages []llm.Message, renderer assistantRenderer) []viewMess for messageIndex, message := range messages { text := message.Text() view := viewMessage{ + Index: messageIndex, Role: string(message.Role), Label: messageRoleLabel(message.Role), Text: text, @@ -1076,6 +1084,10 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { stream, err := session.Send(j.ctx, j.prompt, opts) if err != nil { + if j.shouldAbort(err) { + j.emitAbortedAfterCommit(session, nil, opts) + return + } j.emitError(err) return } @@ -1089,11 +1101,19 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { event, err := stream.Next() if errors.Is(err, io.EOF) { if !completed { + if j.wasAbortRequested() { + j.emitAbortedAfterCommit(session, stream, opts) + return + } j.emitError(io.ErrUnexpectedEOF) } return } if err != nil { + if j.shouldAbort(err) { + j.emitAbortedAfterCommit(session, stream, opts) + return + } j.emitError(err) return } @@ -1144,6 +1164,10 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { } } +func (j *turnJob) shouldAbort(err error) bool { + return j.wasAbortRequested() || errors.Is(err, context.Canceled) +} + func appendOutputDelta(parts *[]llm.Part, partType llm.PartType, delta string) { if delta == "" { return @@ -1200,13 +1224,35 @@ func escapedPlainTextHTML(text string) template.HTML { } func (j *turnJob) emitError(err error) { - if j.wasAbortRequested() || errors.Is(err, context.Canceled) { - j.emitTerminal("aborted", abortedEvent{ - TurnID: j.id, - AssistantMessageID: j.assistantMessageID, - }) + if j.shouldAbort(err) { + j.emitAborted() return } + j.emitStreamError() +} + +func (j *turnJob) emitAbortedAfterCommit(session *chat.Session, stream *chat.TurnStream, opts chat.SendOptions) { + var err error + if stream != nil { + err = stream.CommitPartial() + } else { + err = session.CommitStopped(context.Background(), j.prompt, opts) + } + if err != nil { + j.emitStreamError() + return + } + j.emitAborted() +} + +func (j *turnJob) emitAborted() { + j.emitTerminal("aborted", abortedEvent{ + TurnID: j.id, + AssistantMessageID: j.assistantMessageID, + }) +} + +func (j *turnJob) emitStreamError() { j.emitTerminal("stream-error", errorEvent{ TurnID: j.id, AssistantMessageID: j.assistantMessageID, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 88629c5..7ded2c1 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -85,8 +85,17 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `class="chat-panel"`, `id="scroll-bottom"`, `id="composer-status"`, + `id="composer-dock"`, + `id="undo-button"`, + `id="redo-button"`, + `id="composer-action"`, + `data-action-state="send"`, + `data-action-icon="send"`, + `data-action-icon="stop"`, + `id="dirty-dialog"`, + `aria-label="Previous prompt"`, + `aria-label="Next prompt"`, `aria-label="Send message"`, - `aria-label="Stop response"`, `id="theme-toggle"`, `data-theme-toggle`, `aria-label="Current theme: system preference"`, @@ -776,6 +785,10 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `status-sweep`, `@keyframes status-sweep`, `.message-status`, + `.message-edit-slot`, + `.action-button`, + `.history-button`, + `.dirty-dialog`, `animation: status-sweep 2.2s`, `--status-sweep-low: rgb(32 32 32);`, `min-height: 2.1rem;`, @@ -826,6 +839,12 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `const nearBottomThreshold = 32;`, `const wasNearBottom = isNearBottom();`, `scrollToBottom(false, wasNearBottom);`, + `replace_from`, + `markTurnStopped`, + `requestNavigation`, + `ArrowUp`, + `ArrowDown`, + `data-action-icon`, } { if !strings.Contains(body, want) { t.Fatalf("app JS = %q, want streaming UI behavior %q", body, want) @@ -1099,6 +1118,33 @@ func TestCreateTurnRejectsConcurrentTurn(t *testing.T) { } } +func TestCreateTurnRejectsInvalidReplaceFrom(t *testing.T) { + server := httptest.NewServer(NewServer(Options{Client: dummy.NewClient(dummy.Turn{TextChunks: []string{"answer"}})})) + defer server.Close() + + client := testHTTPClient(t) + csrfToken := fetchCSRFToken(t, client, server.URL) + turn := createTurn(t, client, server.URL, csrfToken, "first") + response, body := get(t, client, server.URL+turn.StreamURL) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("GET events status = %d, want 200; body = %q", response.StatusCode, body) + } + + for _, replaceFrom := range []int{-1, 1, 3} { + request := newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns", map[string]any{ + "prompt": "replacement", + "replace_from": replaceFrom, + }) + request.Header.Set(csrfHeaderName, csrfToken) + response, body := do(t, client, request) + response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("replace_from=%d status = %d, want 400; body = %q", replaceFrom, response.StatusCode, body) + } + } +} + func TestCreateTurnStartsJobAndStreamsReplayableEvents(t *testing.T) { completedAt := time.Date(2026, 5, 25, 12, 34, 56, 0, time.UTC) withTimeNow(t, func() time.Time { return completedAt }) @@ -1323,6 +1369,75 @@ func TestAbortCancelsTurnJobAndStreamsAbortedEvent(t *testing.T) { } } +func TestAbortedTurnPersistsPartialOutputForFollowUp(t *testing.T) { + llmClient := newControlledClient() + server := httptest.NewServer(NewServer(Options{Client: llmClient})) + defer server.Close() + + client := testHTTPClient(t) + csrfToken := fetchCSRFToken(t, client, server.URL) + turn := createTurn(t, client, server.URL, csrfToken, "hello") + _ = llmClient.waitForContext(t) + + request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+turn.StreamURL, nil) + if err != nil { + t.Fatalf("NewRequest events error = %v", err) + } + response, err := client.Do(request) + if err != nil { + t.Fatalf("GET events error = %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(response.Body) + t.Fatalf("GET events status = %d, want 200; body = %q", response.StatusCode, raw) + } + + select { + case llmClient.events <- llm.Event{Type: llm.EventTextDelta, Delta: "partial"}: + case <-time.After(time.Second): + t.Fatalf("timed out sending partial event") + } + reader := bufio.NewReader(response.Body) + frame := readSSEFrame(t, reader) + if frame.Event != "preview" || !strings.Contains(string(frame.Data), "partial") { + t.Fatalf("first frame = %#v, want partial preview", frame) + } + + request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+turn.TurnID+"/abort", nil) + request.Header.Set(csrfHeaderName, csrfToken) + abortResponse, abortBody := do(t, client, request) + defer abortResponse.Body.Close() + if abortResponse.StatusCode != http.StatusOK { + t.Fatalf("abort status = %d, want 200; body = %q", abortResponse.StatusCode, abortBody) + } + remaining, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll remaining events error = %v", err) + } + if !hasFrame(parseSSE(t, string(remaining)), "aborted", `"turn_id":"`+turn.TurnID+`"`) { + t.Fatalf("remaining SSE body = %q, want aborted event", remaining) + } + + next := createTurn(t, client, server.URL, csrfToken, "follow up") + _ = llmClient.waitForContext(t) + request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+next.TurnID+"/abort", nil) + request.Header.Set(csrfHeaderName, csrfToken) + cleanupResponse, cleanupBody := do(t, client, request) + defer cleanupResponse.Body.Close() + if cleanupResponse.StatusCode != http.StatusOK { + t.Fatalf("cleanup abort status = %d, want 200; body = %q", cleanupResponse.StatusCode, cleanupBody) + } + + requests := llmClient.Requests() + if len(requests) != 2 { + t.Fatalf("request count = %d, want 2", len(requests)) + } + if got := requests[1].Messages; len(got) != 3 || got[0].Text() != "hello" || got[1].Text() != "partial" || got[2].Text() != "follow up" { + t.Fatalf("follow-up request messages = %#v, want stopped partial turn in context", requests[1].Messages) + } +} + func TestAbortRequiresCSRFAndRejectsFinishedTurn(t *testing.T) { server := httptest.NewServer(NewServer(Options{Client: dummy.NewClient()})) defer server.Close() @@ -1514,6 +1629,46 @@ func TestCompletedTurnsUseSameChatSessionForFollowUp(t *testing.T) { } } +func TestReplaceFromTruncatesConversationContextForFollowUp(t *testing.T) { + llmClient := &recordingClient{} + server := httptest.NewServer(NewServer(Options{Client: llmClient})) + defer server.Close() + + client := testHTTPClient(t) + csrfToken := fetchCSRFToken(t, client, server.URL) + + first := createTurn(t, client, server.URL, csrfToken, "first") + response, body := get(t, client, server.URL+first.StreamURL) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("first events status = %d, want 200; body = %q", response.StatusCode, body) + } + second := createTurn(t, client, server.URL, csrfToken, "second") + response, body = get(t, client, server.URL+second.StreamURL) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("second events status = %d, want 200; body = %q", response.StatusCode, body) + } + + replacement := createTurnWithPayload(t, client, server.URL, csrfToken, map[string]any{ + "prompt": "edited second", + "replace_from": 2, + }) + response, body = get(t, client, server.URL+replacement.StreamURL) + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("replacement events status = %d, want 200; body = %q", response.StatusCode, body) + } + + requests := llmClient.Requests() + if len(requests) != 3 { + t.Fatalf("request count = %d, want 3", len(requests)) + } + if got := requests[2].Messages; len(got) != 3 || got[0].Text() != "first" || got[1].Text() != "answer 1" || got[2].Text() != "edited second" { + t.Fatalf("replacement request messages = %#v, want first turn plus edited prompt", requests[2].Messages) + } +} + func TestIndexRendersCompletedMessagesAndReusesSessionCookie(t *testing.T) { server := httptest.NewServer(NewServer(Options{ Client: dummy.NewClient(dummy.Turn{TextChunks: []string{"**answer**"}}), @@ -1550,6 +1705,8 @@ func TestIndexRendersCompletedMessagesAndReusesSessionCookie(t *testing.T) { for _, want := range []string{ `class="message-actions"`, `data-copy-message`, + `data-message-index="0"`, + `data-editable-prompt="true"`, `aria-label="Copy message"`, } { if !strings.Contains(body, want) { @@ -2511,9 +2668,15 @@ func fetchCSRFToken(t *testing.T, client *http.Client, baseURL string) string { func createTurn(t *testing.T, client *http.Client, baseURL, csrfToken, prompt string) turnResponse { t.Helper() - request := newJSONRequest(t, http.MethodPost, baseURL+"/chat/turns", map[string]string{ + return createTurnWithPayload(t, client, baseURL, csrfToken, map[string]any{ "prompt": prompt, }) +} + +func createTurnWithPayload(t *testing.T, client *http.Client, baseURL, csrfToken string, payload map[string]any) turnResponse { + t.Helper() + + request := newJSONRequest(t, http.MethodPost, baseURL+"/chat/turns", payload) request.Header.Set(csrfHeaderName, csrfToken) response, body := do(t, client, request) defer response.Body.Close() @@ -2521,14 +2684,14 @@ func createTurn(t *testing.T, client *http.Client, baseURL, csrfToken, prompt st t.Fatalf("POST /chat/turns status = %d, want 201; body = %q", response.StatusCode, body) } - var payload turnResponse - if err := json.Unmarshal([]byte(body), &payload); err != nil { + var turn turnResponse + if err := json.Unmarshal([]byte(body), &turn); err != nil { t.Fatalf("decode turn response error = %v; body = %q", err, body) } - if payload.TurnID == "" || payload.UserMessageID == "" || payload.AssistantMessageID == "" || payload.StreamURL == "" { - t.Fatalf("turn response = %#v, want stable ids and stream URL", payload) + if turn.TurnID == "" || turn.UserMessageID == "" || turn.AssistantMessageID == "" || turn.StreamURL == "" { + t.Fatalf("turn response = %#v, want stable ids and stream URL", turn) } - return payload + return turn } func newJSONRequest(t *testing.T, method, url string, payload any) *http.Request { @@ -2805,8 +2968,10 @@ func readSSEFrame(t *testing.T, reader *bufio.Reader) sseFrame { } type controlledClient struct { - events chan llm.Event - ctx chan context.Context + mu sync.Mutex + events chan llm.Event + ctx chan context.Context + requests []llm.Request } func newControlledClient() *controlledClient { @@ -2816,11 +2981,25 @@ func newControlledClient() *controlledClient { } } -func (c *controlledClient) Stream(ctx context.Context, _ llm.Request) (llm.Stream, error) { +func (c *controlledClient) Stream(ctx context.Context, request llm.Request) (llm.Stream, error) { + c.mu.Lock() + c.requests = append(c.requests, request.Clone()) + c.mu.Unlock() c.ctx <- ctx return &controlledStream{ctx: ctx, events: c.events}, nil } +func (c *controlledClient) Requests() []llm.Request { + c.mu.Lock() + defer c.mu.Unlock() + + requests := make([]llm.Request, len(c.requests)) + for i, request := range c.requests { + requests[i] = request.Clone() + } + return requests +} + func (c *controlledClient) waitForContext(t *testing.T) context.Context { t.Helper() diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 22c3561..9424e33 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -62,7 +62,7 @@

Pyttechat

{{range .Messages}} -
+
{{range .Statuses}}
@@ -100,28 +100,53 @@

Pyttechat

-
-
- - -
-

Ready

- - +
+ +
+ + +
+

Ready

+ + + +
-
- + +
+ +
+

Unsaved prompt changes

+

Submit the edited prompt or discard it before moving.

+
+ + + +
+
+
From ff0a8b0ce758180a989716194cfb1c2633ce8837 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 9 Jun 2026 22:47:43 +0200 Subject: [PATCH 02/20] Fix conversation edit rollback --- .gitignore | 1 + internal/web/assets/app.js | 29 ++++++++++++++++++++++++++++- internal/web/server.go | 19 +++++++++++-------- internal/web/server_test.go | 31 +++++++++++++++++++++++++++++++ internal/web/templates/index.html | 2 +- 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index d6aa6d2..0afd986 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea/ +.otel/ .vscode/ *.swp *.swo diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 44e04c3..ee87640 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -125,6 +125,10 @@ messages.querySelectorAll('.message[data-message-index]').forEach(function (article) { maxIndex = Math.max(maxIndex, messageIndex(article)); }); + const serverIndex = Number.parseInt(messages.dataset.nextMessageIndex || '', 10); + if (Number.isFinite(serverIndex) && serverIndex >= 0) { + return Math.max(serverIndex, maxIndex + 1); + } return maxIndex + 1; } @@ -492,14 +496,35 @@ } function truncateMessagesFrom(index) { + const snapshot = { + articles: [], + nextMessageIndex, + }; messages.querySelectorAll('.message[data-message-index]').forEach(function (article) { if (messageIndex(article) >= index) { + snapshot.articles.push(article); article.remove(); } }); nextMessageIndex = index; ensureEmptyState(); updateScrollButton(); + return snapshot; + } + + function restoreTruncatedMessages(snapshot) { + if (!snapshot) { + return; + } + const empty = messages.querySelector('.message-empty'); + if (empty) { + empty.remove(); + } + snapshot.articles.forEach(function (article) { + insertMessage(article); + }); + nextMessageIndex = snapshot.nextMessageIndex; + updateScrollButton(); } function assignMessageIDs(user, assistant, turn) { @@ -1115,8 +1140,9 @@ const replaceFrom = currentEditIndex; moveComposerToDockForSubmit(); + let truncatedSnapshot = null; if (replaceFrom !== null) { - truncateMessagesFrom(replaceFrom); + truncatedSnapshot = truncateMessagesFrom(replaceFrom); } const userIndex = replaceFrom === null ? nextMessageIndex : replaceFrom; @@ -1140,6 +1166,7 @@ } catch (error) { creatingTurn = false; discardTurn(user, assistant); + restoreTruncatedMessages(truncatedSnapshot); finishTurn('Message not sent'); } }); diff --git a/internal/web/server.go b/internal/web/server.go index ef1b404..06a07c4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -136,11 +136,13 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { return } + messages := session.chat.Messages() data := pageData{ - CSRFToken: session.csrf, - ModelLabel: modelDisplayLabel(s.model), - Messages: viewMessages(session.chat.Messages(), s.markdown), - Username: session.username, + CSRFToken: session.csrf, + ModelLabel: modelDisplayLabel(s.model), + Messages: viewMessages(messages, s.markdown), + NextMessageIndex: len(messages), + Username: session.username, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.template.ExecuteTemplate(w, "index.html", data); err != nil { @@ -704,10 +706,11 @@ type createTurnResponse struct { } type pageData struct { - CSRFToken string - ModelLabel string - Username string - Messages []viewMessage + CSRFToken string + ModelLabel string + Username string + Messages []viewMessage + NextMessageIndex int } type authPageData struct { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 7ded2c1..3b6708b 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1725,6 +1725,37 @@ func TestIndexRendersCompletedMessagesAndReusesSessionCookie(t *testing.T) { } } +func TestIndexKeepsNextMessageIndexForHiddenEmptyAssistant(t *testing.T) { + handler := NewServer(Options{Client: dummy.NewClient()}) + chatSession := chat.NewService(dummy.NewClient()).NewSession() + if err := chatSession.CommitStopped(context.Background(), "stopped", chat.SendOptions{}); err != nil { + t.Fatalf("CommitStopped error = %v, want nil", err) + } + handler.sessions["sess_test"] = &browserSession{ + id: "sess_test", + csrf: "csrf_test", + chat: chatSession, + turns: map[string]*turnJob{}, + } + + request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil) + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sess_test"}) + recorder := httptest.NewRecorder() + + handler.ServeHTTP(recorder, request) + + body := recorder.Body.String() + if recorder.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want 200; body = %q", recorder.Code, body) + } + if !strings.Contains(body, `data-next-message-index="2"`) { + t.Fatalf("GET / body = %q, want next message index to include hidden empty assistant", body) + } + if !strings.Contains(body, `data-message-index="0"`) || strings.Contains(body, `data-message-index="1"`) { + t.Fatalf("GET / body = %q, want only visible user message indexed while preserving next index", body) + } +} + func TestModelDisplayLabelDefaultsWhenModelUnset(t *testing.T) { server := httptest.NewServer(NewServer(Options{Client: dummy.NewClient()})) defer server.Close() diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 9424e33..ca5a4c6 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -60,7 +60,7 @@

Pyttechat

-
+
{{range .Messages}}
{{range .Statuses}} From 6ebe8e0d2f1974a7c3f1daaad083bb48ab7773b8 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 9 Jun 2026 22:55:27 +0200 Subject: [PATCH 03/20] Fix conversation editing lint --- internal/storage/sqlite_test.go | 18 +++++++++--------- internal/web/server.go | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 5019ff2..b039203 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -386,11 +386,11 @@ func TestSQLiteReplaceTailAndAppendTurnTruncatesAndAppends(t *testing.T) { t.Fatalf("DefaultConversationForUser error = %v, want nil", err) } - if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer one")); err != nil { - t.Fatalf("AppendTurn first error = %v, want nil", err) + if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer one")); appendErr != nil { + t.Fatalf("AppendTurn first error = %v, want nil", appendErr) } - if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "second"), llm.NewTextMessage(llm.RoleAssistant, "answer two")); err != nil { - t.Fatalf("AppendTurn second error = %v, want nil", err) + if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "second"), llm.NewTextMessage(llm.RoleAssistant, "answer two")); appendErr != nil { + t.Fatalf("AppendTurn second error = %v, want nil", appendErr) } err = store.ReplaceTailAndAppendTurn(ctx, conversation.ID, 2, llm.NewTextMessage(llm.RoleUser, "edited second"), llm.NewTextMessage(llm.RoleAssistant, "replacement answer")) @@ -445,14 +445,14 @@ func TestSQLiteReplaceTailAndAppendTurnRejectsInvalidKeepCount(t *testing.T) { if err != nil { t.Fatalf("DefaultConversationForUser error = %v, want nil", err) } - if err := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer")); err != nil { - t.Fatalf("AppendTurn error = %v, want nil", err) + if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer")); appendErr != nil { + t.Fatalf("AppendTurn error = %v, want nil", appendErr) } for _, keepMessages := range []int{-1, 3} { - err := store.ReplaceTailAndAppendTurn(ctx, conversation.ID, keepMessages, llm.NewTextMessage(llm.RoleUser, "bad"), llm.NewTextMessage(llm.RoleAssistant, "bad answer")) - if !errors.Is(err, ErrInvalidArgument) { - t.Fatalf("ReplaceTailAndAppendTurn keep=%d error = %v, want ErrInvalidArgument", keepMessages, err) + replaceErr := store.ReplaceTailAndAppendTurn(ctx, conversation.ID, keepMessages, llm.NewTextMessage(llm.RoleUser, "bad"), llm.NewTextMessage(llm.RoleAssistant, "bad answer")) + if !errors.Is(replaceErr, ErrInvalidArgument) { + t.Fatalf("ReplaceTailAndAppendTurn keep=%d error = %v, want ErrInvalidArgument", keepMessages, replaceErr) } } diff --git a/internal/web/server.go b/internal/web/server.go index 06a07c4..a2cfdb6 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -184,7 +184,7 @@ func (s *Server) handleCreateTurn(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "empty_prompt", "prompt must not be empty") return } - if err := session.chat.ValidateReplaceFrom(request.ReplaceFrom); err != nil { + if validateErr := session.chat.ValidateReplaceFrom(request.ReplaceFrom); validateErr != nil { writeJSONError(w, http.StatusBadRequest, "invalid_replace_from", "replace_from must point to a user message or the end of the conversation") return } From 0fbed255c31fa1093cb3ed0c7abf1a8e8ca71057 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Fri, 12 Jun 2026 22:27:18 +0200 Subject: [PATCH 04/20] Remove redundant composer status text --- internal/web/assets/app.css | 4 ++++ internal/web/assets/app.js | 10 +++++----- internal/web/server_test.go | 10 ++++++++++ internal/web/templates/index.html | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 91c397b..c7ceec4 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -892,6 +892,10 @@ button { white-space: nowrap; } +.composer-status:empty { + display: none; +} + .icon-button { border-radius: 999px; } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index ee87640..9fac009 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -111,7 +111,7 @@ function setStatus(text) { if (composerStatus) { - composerStatus.textContent = text; + composerStatus.textContent = text || ''; } } @@ -617,7 +617,7 @@ abortRequested = false; creatingTurn = false; updateComposerState(); - setStatus(status || 'Ready'); + setStatus(status || ''); } function markTurnError(assistant, message) { @@ -930,7 +930,7 @@ if (currentTurn === turn) { abortRequested = false; updateComposerState(); - setStatus('Generating response'); + setStatus(''); } throw error; } @@ -1028,7 +1028,7 @@ currentSource.onopen = function () { clearStreamErrorTimer(); - setStatus('Generating response'); + setStatus(''); }; currentSource.addEventListener('preview', function (event) { @@ -1082,7 +1082,7 @@ removeMessage(assistant); } scrollToBottom(false, wasNearBottom); - finishTurn('Response complete'); + finishTurn(); }); currentSource.addEventListener('aborted', function () { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 3b6708b..6ccfa46 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -85,6 +85,7 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `class="chat-panel"`, `id="scroll-bottom"`, `id="composer-status"`, + `

`, `id="composer-dock"`, `id="undo-button"`, `id="redo-button"`, @@ -789,6 +790,7 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.action-button`, `.history-button`, `.dirty-dialog`, + `.composer-status:empty`, `animation: status-sweep 2.2s`, `--status-sweep-low: rgb(32 32 32);`, `min-height: 2.1rem;`, @@ -853,6 +855,14 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { if strings.Contains(body, `prompt.focus()`) { t.Fatalf("app JS = %q, did not expect turn completion to focus composer", body) } + for _, unwanted := range []string{ + `Generating response`, + `Response complete`, + } { + if strings.Contains(body, unwanted) { + t.Fatalf("app JS = %q, did not expect redundant composer status %q", body, unwanted) + } + } response, body = get(t, client, server.URL+"/assets/theme-init.js") defer response.Body.Close() diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index ca5a4c6..3c7fa7c 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -106,7 +106,7 @@

Pyttechat

-

Ready

+

- -
+ {{if eq .Role "assistant"}} +
+ +
+ {{end}} {{else}}
@@ -93,7 +95,7 @@

Pyttechat

{{end}}
- - - +
+
From 6ad9a775ba6d05582c13bf1b06a0dcc53f63f7e8 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sat, 13 Jun 2026 17:52:01 +0200 Subject: [PATCH 08/20] Make interrupted responses final stops --- internal/auth/auth_test.go | 4 - internal/chat/service.go | 132 +-------- internal/chat/service_test.go | 238 +--------------- internal/storage/sqlite.go | 51 ---- internal/storage/sqlite_test.go | 106 -------- internal/storage/storage.go | 1 - internal/web/assets/app.css | 5 +- internal/web/assets/app.js | 92 +------ internal/web/server.go | 257 +----------------- internal/web/server_test.go | 433 +----------------------------- internal/web/templates/index.html | 8 +- 11 files changed, 31 insertions(+), 1296 deletions(-) diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 23e67e6..60e1dd3 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -384,10 +384,6 @@ func (s authTestStore) ReplaceTailAndAppendTurn(context.Context, int64, int, llm return nil } -func (s authTestStore) ReplaceLastAssistant(context.Context, int64, llm.Message) error { - return nil -} - func (s authTestStore) Close() error { return nil } diff --git a/internal/chat/service.go b/internal/chat/service.go index 165d1bd..3a4954b 100644 --- a/internal/chat/service.go +++ b/internal/chat/service.go @@ -14,7 +14,6 @@ import ( var ( ErrEmptyPrompt = errors.New("prompt must not be empty") ErrInvalidReplaceFrom = errors.New("replace_from must point to a user message or the end of the conversation") - ErrInvalidResume = errors.New("resume requires a stopped assistant response") ErrTurnInProgress = errors.New("turn already in progress") ) @@ -35,7 +34,6 @@ type Store interface { Messages(context.Context, int64) ([]llm.Message, error) AppendTurn(context.Context, int64, llm.Message, llm.Message) error ReplaceTailAndAppendTurn(context.Context, int64, int, llm.Message, llm.Message) error - ReplaceLastAssistant(context.Context, int64, llm.Message) error } func (s Service) NewSession() *Session { @@ -75,14 +73,6 @@ type SendOptions struct { ReplaceFrom *int } -type ResumeOptions struct { - Model string - ReasoningEffort string - RenderingInstructions string - TelemetryComponent string - ContinuationPrompt string -} - func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*TurnStream, error) { prompt = strings.TrimSpace(prompt) if prompt == "" { @@ -140,66 +130,6 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T }, nil } -func (s *Session) ResumeLastAssistant(ctx context.Context, opts ResumeOptions) (*TurnStream, error) { - prompt := strings.TrimSpace(opts.ContinuationPrompt) - if prompt == "" { - return nil, ErrEmptyPrompt - } - - ctx, span := observability.StartSpan(ctx, "chat.turn.resume", opts.TelemetryComponent) - startedAt := time.Now() - defer span.End() - - request := llm.Request{ - Model: opts.Model, - Instructions: strings.TrimSpace(opts.RenderingInstructions), - } - if effort := strings.TrimSpace(opts.ReasoningEffort); effort != "" { - request.Reasoning = llm.ReasoningOptions{ - Summary: "auto", - Effort: effort, - } - } - - continuationMessage := llm.NewTextMessage(llm.RoleUser, prompt) - - s.mu.Lock() - if s.inFlight { - s.mu.Unlock() - observability.RecordSpanError(span, ErrTurnInProgress) - return nil, ErrTurnInProgress - } - lastIndex := len(s.messages) - 1 - if lastIndex < 0 || s.messages[lastIndex].Role != llm.RoleAssistant { - s.mu.Unlock() - observability.RecordSpanError(span, ErrInvalidResume) - return nil, ErrInvalidResume - } - baseAssistant := s.messages[lastIndex].Clone() - request.Messages = append(llm.CloneMessages(s.messages), continuationMessage.Clone()) - s.inFlight = true - s.mu.Unlock() - - observability.ChatTurnStarted(ctx) - stream, err := s.client.Stream(ctx, request) - if err != nil { - s.releaseTurn() - observability.ChatTurnFailed(ctx, startedAt, err) - observability.RecordSpanError(span, err) - return nil, err - } - - return &TurnStream{ - session: s, - stream: stream, - userMessage: continuationMessage, - ctx: ctx, - startedAt: startedAt, - resumeBaseAssistant: baseAssistant, - resume: true, - }, nil -} - func (s *Session) Messages() []llm.Message { s.mu.Lock() defer s.mu.Unlock() @@ -246,9 +176,6 @@ type TurnStream struct { completed bool finalized bool recorded bool - - resumeBaseAssistant llm.Message - resume bool } func (s *TurnStream) Next() (llm.Event, error) { @@ -357,20 +284,11 @@ func (s *TurnStream) finalize(allowIncomplete bool) error { Role: llm.RoleAssistant, Parts: cloneParts(s.assistantParts), } - if s.resume { - assistant.Parts = mergeAssistantParts(s.resumeBaseAssistant.Parts, assistant.Parts) - } s.session.mu.Lock() defer s.session.mu.Unlock() - if s.resume { - if err := s.session.replaceLastAssistantLocked(context.Background(), assistant); err != nil { - return err - } - } else { - if err := s.session.replaceTailLocked(context.Background(), s.keepMessages, s.userMessage, assistant); err != nil { - return err - } + if err := s.session.replaceTailLocked(context.Background(), s.keepMessages, s.userMessage, assistant); err != nil { + return err } s.finalized = true return nil @@ -448,52 +366,6 @@ func (s *Session) replaceTailLocked(ctx context.Context, keepMessages int, userM return nil } -func (s *Session) replaceLastAssistantLocked(ctx context.Context, assistant llm.Message) error { - lastIndex := len(s.messages) - 1 - if lastIndex < 0 || s.messages[lastIndex].Role != llm.RoleAssistant || assistant.Role != llm.RoleAssistant { - return ErrInvalidResume - } - if s.store != nil { - if err := s.store.ReplaceLastAssistant(ctx, s.conversationID, assistant.Clone()); err != nil { - return err - } - } - nextMessages := llm.CloneMessages(s.messages) - nextMessages[lastIndex] = assistant.Clone() - s.messages = nextMessages - s.inFlight = false - return nil -} - -func mergeAssistantParts(base, continuation []llm.Part) []llm.Part { - parts := cloneParts(base) - for _, part := range continuation { - if part.Type == "" { - continue - } - lastIndex := len(parts) - 1 - if lastIndex >= 0 && canMergeAssistantParts(parts[lastIndex], part) { - parts[lastIndex].Text += part.Text - if len(part.Summary) > 0 { - parts[lastIndex].Summary = append(parts[lastIndex].Summary, part.Summary...) - } - if part.ID != "" { - parts[lastIndex].ID = part.ID - } - if part.EncryptedContent != "" { - parts[lastIndex].EncryptedContent = part.EncryptedContent - } - continue - } - parts = append(parts, part.Clone()) - } - return parts -} - -func canMergeAssistantParts(left, right llm.Part) bool { - return (left.Type == llm.PartText || left.Type == llm.PartReasoning) && left.Type == right.Type -} - func cloneParts(parts []llm.Part) []llm.Part { if parts == nil { return nil diff --git a/internal/chat/service_test.go b/internal/chat/service_test.go index fc63e10..38674fe 100644 --- a/internal/chat/service_test.go +++ b/internal/chat/service_test.go @@ -414,217 +414,6 @@ func TestSessionCanCommitPartialTurn(t *testing.T) { } } -func TestSessionResumeLastAssistantContinuesPartialWithoutPersistingHiddenPrompt(t *testing.T) { - client := dummy.NewClient(dummy.Turn{TextChunks: []string{" continued"}}) - session := NewService(client).NewSession() - if err := session.CommitStopped(context.Background(), "hello", SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - session.messages[1] = llm.NewTextMessage(llm.RoleAssistant, "partial") - - stream, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ - ContinuationPrompt: "Continue from where you stopped.", - Model: "resume-model", - ReasoningEffort: "medium", - TelemetryComponent: "test", - RenderingInstructions: "render", - }) - if err != nil { - t.Fatalf("ResumeLastAssistant error = %v, want nil", err) - } - collectEvents(t, stream) - - requests := client.Requests() - if len(requests) != 1 { - t.Fatalf("request count = %d, want 1", len(requests)) - } - if requests[0].Instructions != "render" { - t.Fatalf("request instructions = %q, want render", requests[0].Instructions) - } - if requests[0].Model != "resume-model" { - t.Fatalf("request model = %q, want resume-model", requests[0].Model) - } - if requests[0].Reasoning.Effort != "medium" || requests[0].Reasoning.Summary != "auto" { - t.Fatalf("request reasoning = %#v, want medium effort with auto summary", requests[0].Reasoning) - } - if got := requests[0].Messages; len(got) != 3 || got[0].Text() != "hello" || got[1].Text() != "partial" || got[2].Role != llm.RoleUser || got[2].Text() != "Continue from where you stopped." { - t.Fatalf("resume request messages = %#v, want prior turn plus hidden continuation prompt", requests[0].Messages) - } - - messages := session.Messages() - if len(messages) != 2 { - t.Fatalf("message count = %d, want original user plus merged assistant", len(messages)) - } - if messages[0].Text() != "hello" || messages[1].Text() != "partial continued" { - t.Fatalf("messages = %#v, want hidden prompt omitted and assistant merged", messages) - } -} - -func TestSessionResumeLastAssistantRejectsInvalidHistory(t *testing.T) { - session := NewService(dummy.NewClient()).NewSession() - if _, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}); !errors.Is(err, ErrInvalidResume) { - t.Fatalf("ResumeLastAssistant(empty) error = %v, want ErrInvalidResume", err) - } - if err := session.CommitStopped(context.Background(), "hello", SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - session.messages = session.messages[:1] - if _, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}); !errors.Is(err, ErrInvalidResume) { - t.Fatalf("ResumeLastAssistant(no assistant) error = %v, want ErrInvalidResume", err) - } - if _, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: " "}); !errors.Is(err, ErrEmptyPrompt) { - t.Fatalf("ResumeLastAssistant(empty prompt) error = %v, want ErrEmptyPrompt", err) - } -} - -func TestPersistentSessionResumeReplacesLastAssistant(t *testing.T) { - store := &chatStore{ - messages: []llm.Message{ - llm.NewTextMessage(llm.RoleUser, "stored prompt"), - llm.NewTextMessage(llm.RoleAssistant, "partial"), - }, - } - client := dummy.NewClient(dummy.Turn{TextChunks: []string{" continued"}}) - session, err := NewPersistentService(client, store).NewPersistedSession(context.Background(), 42) - if err != nil { - t.Fatalf("NewPersistedSession error = %v, want nil", err) - } - - stream, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}) - if err != nil { - t.Fatalf("ResumeLastAssistant error = %v, want nil", err) - } - collectEvents(t, stream) - - if len(store.replacedAssistant) != 1 { - t.Fatalf("replaced assistant count = %d, want 1", len(store.replacedAssistant)) - } - replaced := store.replacedAssistant[0] - if replaced.conversationID != 42 || replaced.assistant.Text() != "partial continued" { - t.Fatalf("replaced assistant = %#v, want merged assistant in conversation 42", replaced) - } - if len(store.replaced) != 0 || len(store.appended) != 0 { - t.Fatalf("visible turn persistence = appended %d replaced %d, want none", len(store.appended), len(store.replaced)) - } -} - -func TestPersistentSessionResumeReplaceFailureKeepsPartialAndReleasesTurn(t *testing.T) { - store := &chatStore{ - messages: []llm.Message{ - llm.NewTextMessage(llm.RoleUser, "stored prompt"), - llm.NewTextMessage(llm.RoleAssistant, "partial"), - }, - appendErr: errors.New("replace failed"), - } - client := dummy.NewClient( - dummy.Turn{TextChunks: []string{" failed continuation"}}, - dummy.Turn{TextChunks: []string{" continued"}}, - ) - session, err := NewPersistentService(client, store).NewPersistedSession(context.Background(), 42) - if err != nil { - t.Fatalf("NewPersistedSession error = %v, want nil", err) - } - - stream, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}) - if err != nil { - t.Fatalf("ResumeLastAssistant error = %v, want nil", err) - } - for { - _, err = stream.Next() - if err != nil { - break - } - } - if err == nil || !strings.Contains(err.Error(), "replace failed") { - t.Fatalf("stream error = %v, want replace failed", err) - } - if messages := session.Messages(); len(messages) != 2 || messages[1].Text() != "partial" { - t.Fatalf("messages after failed resume = %#v, want original partial assistant", messages) - } - - store.appendErr = nil - retry, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "retry"}) - if err != nil { - t.Fatalf("retry ResumeLastAssistant error = %v, want nil", err) - } - collectEvents(t, retry) - if messages := session.Messages(); len(messages) != 2 || messages[1].Text() != "partial continued" { - t.Fatalf("messages after retry = %#v, want resumed assistant", messages) - } -} - -func TestSessionResumeLastAssistantReleasesTurnWhenStreamFailsToStart(t *testing.T) { - session := NewService(streamStartFailingClient{}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - - _, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}) - if err == nil { - t.Fatalf("ResumeLastAssistant error = nil, want stream start failure") - } - - session.client = eventClient{events: []llm.Event{{Type: llm.EventCompleted}}} - stream, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "retry"}) - if err != nil { - t.Fatalf("retry ResumeLastAssistant error = %v, want nil", err) - } - if closeErr := stream.Close(); closeErr != nil { - t.Fatalf("retry Close() error = %v, want nil", closeErr) - } -} - -func TestSessionResumeLastAssistantRejectsConcurrentTurn(t *testing.T) { - session := NewService(eventClient{}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - - stream, err := session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "continue"}) - if err != nil { - t.Fatalf("ResumeLastAssistant error = %v, want nil", err) - } - _, err = session.ResumeLastAssistant(context.Background(), ResumeOptions{ContinuationPrompt: "again"}) - if !errors.Is(err, ErrTurnInProgress) { - t.Fatalf("concurrent ResumeLastAssistant error = %v, want ErrTurnInProgress", err) - } - if closeErr := stream.Close(); closeErr != nil { - t.Fatalf("Close() error = %v, want nil", closeErr) - } -} - -func TestMergeAssistantPartsBranchVariants(t *testing.T) { - parts := mergeAssistantParts([]llm.Part{ - {Type: llm.PartText, Text: "partial"}, - {Type: llm.PartReasoning, Text: "rough"}, - }, []llm.Part{ - {}, - {Type: llm.PartReasoning, Text: " final", ID: "rs_1", Summary: []string{"summary"}, EncryptedContent: "encrypted"}, - {Type: llm.PartText, Text: " answer"}, - {Type: llm.PartText, Text: " continued"}, - {Type: llm.PartError, Text: "warning"}, - }) - - if len(parts) != 4 { - t.Fatalf("parts = %#v, want merged text/reasoning plus error", parts) - } - if parts[0].Text != "partial" { - t.Fatalf("first text part = %#v, want unchanged base text before reasoning", parts[0]) - } - if parts[1].Text != "rough final" || parts[1].ID != "rs_1" || len(parts[1].Summary) != 1 || parts[1].EncryptedContent != "encrypted" { - t.Fatalf("reasoning part = %#v, want merged continuation metadata", parts[1]) - } - if parts[2].Text != " answer continued" { - t.Fatalf("continuation text part = %#v, want adjacent text merged", parts[2]) - } - if parts[3].Type != llm.PartError { - t.Fatalf("last part = %#v, want non-mergeable error appended", parts[3]) - } - if got := mergeAssistantParts(nil, nil); got != nil { - t.Fatalf("mergeAssistantParts(nil, nil) = %#v, want nil", got) - } -} - func TestPersistentSessionReturnsAppendFailureAndDoesNotKeepInFlight(t *testing.T) { store := &chatStore{appendErr: errors.New("append failed")} session, err := NewPersistentService(dummy.NewClient(dummy.Turn{TextChunks: []string{"answer"}}), store).NewPersistedSession(context.Background(), 42) @@ -1086,12 +875,11 @@ func (*eventStream) Close() error { } type chatStore struct { - messages []llm.Message - messagesErr error - appended []appendedTurn - replaced []replacedTurn - replacedAssistant []replacedAssistant - appendErr error + messages []llm.Message + messagesErr error + appended []appendedTurn + replaced []replacedTurn + appendErr error } type appendedTurn struct { @@ -1107,11 +895,6 @@ type replacedTurn struct { assistant llm.Message } -type replacedAssistant struct { - conversationID int64 - assistant llm.Message -} - func (s *chatStore) Messages(context.Context, int64) ([]llm.Message, error) { if s.messagesErr != nil { return nil, s.messagesErr @@ -1144,17 +927,6 @@ func (s *chatStore) ReplaceTailAndAppendTurn(_ context.Context, conversationID i return nil } -func (s *chatStore) ReplaceLastAssistant(_ context.Context, conversationID int64, assistant llm.Message) error { - if s.appendErr != nil { - return s.appendErr - } - s.replacedAssistant = append(s.replacedAssistant, replacedAssistant{ - conversationID: conversationID, - assistant: assistant.Clone(), - }) - return nil -} - func collectEvents(t *testing.T, stream *TurnStream) []llm.Event { t.Helper() defer stream.Close() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 19a0368..95c1c72 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -458,57 +458,6 @@ WHERE conversation_id = ? AND sequence > ? return tx.Commit() } -func (s *SQLite) ReplaceLastAssistant(ctx context.Context, conversationID int64, assistantMessage llm.Message) error { - if conversationID <= 0 || assistantMessage.Role != llm.RoleAssistant { - return ErrInvalidArgument - } - assistantParts, err := json.Marshal(assistantMessage.Parts) - if err != nil { - return err - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer rollback(tx) - - var sequence int - var role llm.Role - err = tx.QueryRowContext(ctx, ` -SELECT sequence, role -FROM messages -WHERE conversation_id = ? -ORDER BY sequence DESC -LIMIT 1 -`, conversationID).Scan(&sequence, &role) - if errors.Is(err, sql.ErrNoRows) { - return ErrInvalidArgument - } - if err != nil { - return err - } - if role != llm.RoleAssistant { - return ErrInvalidArgument - } - - result, err := tx.ExecContext(ctx, ` -UPDATE messages -SET parts_json = ? -WHERE conversation_id = ? AND sequence = ? -`, string(assistantParts), conversationID, sequence) - if err != nil { - return err - } - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - if rowsAffected != 1 { - return ErrInvalidArgument - } - return tx.Commit() -} - func insertTurnAtSequence(ctx context.Context, tx *sql.Tx, conversationID int64, firstSequence int, userMessage, assistantMessage llm.Message) error { userParts, err := json.Marshal(userMessage.Parts) if err != nil { diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index eb485c5..b039203 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -410,112 +410,6 @@ func TestSQLiteReplaceTailAndAppendTurnTruncatesAndAppends(t *testing.T) { } } -func TestSQLiteReplaceLastAssistantUpdatesOnlyFinalAssistant(t *testing.T) { - store := newMigratedTestSQLite(t) - ctx := context.Background() - user := createTestUser(t, store, "drew") - conversation, err := store.DefaultConversationForUser(ctx, user.ID) - if err != nil { - t.Fatalf("DefaultConversationForUser error = %v, want nil", err) - } - if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer one")); appendErr != nil { - t.Fatalf("AppendTurn first error = %v, want nil", appendErr) - } - if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "second"), llm.NewTextMessage(llm.RoleAssistant, "partial")); appendErr != nil { - t.Fatalf("AppendTurn second error = %v, want nil", appendErr) - } - - assistant := llm.Message{ - Role: llm.RoleAssistant, - Parts: []llm.Part{ - {Type: llm.PartReasoning, Summary: []string{"summary"}}, - {Type: llm.PartText, Text: "partial continued"}, - }, - } - if err := store.ReplaceLastAssistant(ctx, conversation.ID, assistant); err != nil { - t.Fatalf("ReplaceLastAssistant error = %v, want nil", err) - } - assistant.Parts[1].Text = "mutated" - - messages, err := store.Messages(ctx, conversation.ID) - if err != nil { - t.Fatalf("Messages error = %v, want nil", err) - } - if len(messages) != 4 { - t.Fatalf("message count = %d, want 4", len(messages)) - } - if messages[0].Text() != "first" || messages[1].Text() != "answer one" || messages[2].Text() != "second" || messages[3].Text() != "partial continued" { - t.Fatalf("messages = %#v, want final assistant replaced in place", messages) - } - if messages[3].Parts[0].Type != llm.PartReasoning || len(messages[3].Parts[0].Summary) != 1 { - t.Fatalf("replacement assistant parts = %#v, want full parts JSON persisted", messages[3].Parts) - } -} - -func TestSQLiteReplaceLastAssistantRejectsInvalidInputWithoutPersisting(t *testing.T) { - store := newMigratedTestSQLite(t) - ctx := context.Background() - user := createTestUser(t, store, "duncan") - conversation, err := store.DefaultConversationForUser(ctx, user.ID) - if err != nil { - t.Fatalf("DefaultConversationForUser error = %v, want nil", err) - } - if appendErr := store.AppendTurn(ctx, conversation.ID, llm.NewTextMessage(llm.RoleUser, "first"), llm.NewTextMessage(llm.RoleAssistant, "answer")); appendErr != nil { - t.Fatalf("AppendTurn error = %v, want nil", appendErr) - } - - for _, tc := range []struct { - name string - conversationID int64 - assistant llm.Message - }{ - {name: "missing conversation", conversationID: conversation.ID + 999, assistant: llm.NewTextMessage(llm.RoleAssistant, "replacement")}, - {name: "zero conversation", conversationID: 0, assistant: llm.NewTextMessage(llm.RoleAssistant, "replacement")}, - {name: "wrong role", conversationID: conversation.ID, assistant: llm.NewTextMessage(llm.RoleUser, "replacement")}, - } { - if err := store.ReplaceLastAssistant(ctx, tc.conversationID, tc.assistant); !errors.Is(err, ErrInvalidArgument) { - t.Fatalf("%s ReplaceLastAssistant error = %v, want ErrInvalidArgument", tc.name, err) - } - } - - messages, err := store.Messages(ctx, conversation.ID) - if err != nil { - t.Fatalf("Messages error = %v, want nil", err) - } - if len(messages) != 2 || messages[1].Text() != "answer" { - t.Fatalf("messages after rejected replacement = %#v, want original assistant intact", messages) - } -} - -func TestSQLiteReplaceLastAssistantRejectsConversationEndingInUserMessage(t *testing.T) { - store := newMigratedTestSQLite(t) - ctx := context.Background() - user := createTestUser(t, store, "duane") - conversation, err := store.DefaultConversationForUser(ctx, user.ID) - if err != nil { - t.Fatalf("DefaultConversationForUser error = %v, want nil", err) - } - if _, err := store.db.ExecContext(ctx, ` -INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at) -VALUES (?, 1, 'user', '[{"type":"text","text":"question"}]', ?) -`, conversation.ID, formatTime(time.Now().UTC())); err != nil { - t.Fatalf("insert user message error = %v, want nil", err) - } - - err = store.ReplaceLastAssistant(ctx, conversation.ID, llm.NewTextMessage(llm.RoleAssistant, "answer")) - if !errors.Is(err, ErrInvalidArgument) { - t.Fatalf("ReplaceLastAssistant ending in user message error = %v, want ErrInvalidArgument", err) - } - - messages, err := store.Messages(ctx, conversation.ID) - if err != nil { - t.Fatalf("Messages error = %v, want nil", err) - } - if len(messages) != 1 || messages[0].Role != llm.RoleUser || messages[0].Text() != "question" { - t.Fatalf("messages after rejected replacement = %#v, want original user message intact", messages) - } -} - func TestSQLiteAppendTurnRejectsInvalidInputWithoutPersisting(t *testing.T) { store := newMigratedTestSQLite(t) ctx := context.Background() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index acefb9a..3755252 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -26,7 +26,6 @@ type Store interface { Messages(context.Context, int64) ([]llm.Message, error) AppendTurn(context.Context, int64, llm.Message, llm.Message) error ReplaceTailAndAppendTurn(context.Context, int64, int, llm.Message, llm.Message) error - ReplaceLastAssistant(context.Context, int64, llm.Message) error Close() error } diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 5e678f5..ffc4864 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -987,9 +987,8 @@ button { color: var(--icon-button-hover-color); } -.action-button[data-action-state="pause"], -.action-button[data-action-state="pausing"], -.action-button[data-action-state="resume"] { +.action-button[data-action-state="stop"], +.action-button[data-action-state="stopping"] { color: var(--icon-button-color); } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 769ae04..394da7f 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -29,8 +29,6 @@ let streamErrorTimer = null; let abortRequested = false; let creatingTurn = false; - let resumableTurn = null; - let resumableAssistant = null; let statusIDCounter = 0; let nextMessageIndex = initialNextMessageIndex(); let currentEditIndex = null; @@ -119,24 +117,6 @@ } } - function clearResumableTurn() { - resumableTurn = null; - resumableAssistant = null; - } - - function setResumableTurn(turn, assistant) { - if (!turn || !assistant) { - clearResumableTurn(); - return; - } - resumableTurn = turn; - resumableAssistant = assistant; - } - - function canResumeResponse() { - return Boolean(resumableTurn && resumableAssistant && currentEditIndex === null && prompt.value.trim() === '' && !currentTurn && !creatingTurn); - } - function messageIndex(article) { const value = Number.parseInt(article?.dataset.messageIndex || '', 10); return Number.isFinite(value) ? value : -1; @@ -634,17 +614,16 @@ function updateComposerState() { const submitting = Boolean(currentTurn) || creatingTurn; - const canResume = canResumeResponse(); if (actionButton) { - const state = currentTurn ? (abortRequested ? 'pausing' : 'pause') : (canResume ? 'resume' : 'send'); - const label = currentTurn ? (abortRequested ? 'Pausing response' : 'Pause response') : (canResume ? 'Resume response' : 'Send message'); - const iconName = currentTurn ? 'pause' : 'play'; + const state = currentTurn ? (abortRequested ? 'stopping' : 'stop') : 'send'; + const label = currentTurn ? (abortRequested ? 'Stopping response' : 'Stop response') : 'Send message'; + const iconName = currentTurn ? 'stop' : 'play'; actionButton.dataset.actionState = state; - actionButton.disabled = currentTurn ? abortRequested : creatingTurn || (!canResume && prompt.value.trim() === ''); + actionButton.disabled = currentTurn ? abortRequested : creatingTurn || prompt.value.trim() === ''; actionButton.setAttribute('aria-label', label); actionButton.title = label; actionIcons.forEach(function (icon) { - icon.hidden = icon.dataset.actionIcon !== iconName; + icon.toggleAttribute('hidden', icon.dataset.actionIcon !== iconName); }); } updateHistoryButtons(submitting); @@ -992,19 +971,6 @@ } } - async function resumeTurn(turn) { - const response = await fetch(`/chat/turns/${encodeURIComponent(turn.turn_id)}/resume`, { - method: 'POST', - headers: { - [csrfHeaderName()]: csrfToken, - }, - }); - if (!response.ok) { - throw new Error((await response.text()) || 'The response could not be resumed.'); - } - return response.json(); - } - async function requestAbort(turn) { abortRequested = true; updateComposerState(); @@ -1021,44 +987,6 @@ } } - function prepareAssistantForResume(assistant) { - if (!assistant) { - return; - } - assistant.article.classList.remove('message-stopped', 'message-error', 'message-complete'); - assistant.article.classList.add('message-streaming'); - const note = assistant.article.querySelector('.message-stopped-note'); - if (note) { - note.remove(); - } - } - - async function startResume() { - if (!canResumeResponse()) { - return; - } - const sourceTurn = resumableTurn; - const assistant = resumableAssistant; - creatingTurn = true; - prepareAssistantForResume(assistant); - updateComposerState(); - setStatus('Resuming'); - try { - const turn = await resumeTurn(sourceTurn); - clearResumableTurn(); - currentAssistant = assistant; - creatingTurn = false; - subscribe(turn, null, assistant); - updateComposerState(); - } catch (error) { - creatingTurn = false; - setResumableTurn(sourceTurn, assistant); - markTurnStopped(assistant); - updateComposerState(); - setStatus('Resume failed'); - } - } - function navigateTo(targetIndex, selection) { moveComposerTo(targetIndex, selection || (targetIndex === null ? 'end' : 'end')); } @@ -1290,7 +1218,6 @@ if (!assistantHasContent(assistant)) { removeMessage(assistant); } - clearResumableTurn(); scrollToBottom(false, wasNearBottom); finishTurn(); }); @@ -1301,7 +1228,6 @@ } clearStreamErrorTimer(); markTurnStopped(assistant); - setResumableTurn(turn, assistant); finishTurn(''); }); @@ -1318,7 +1244,6 @@ message = 'The response stream failed.'; } markTurnError(assistant, message); - clearResumableTurn(); finishTurn('Stream failed'); }); @@ -1352,7 +1277,6 @@ const replaceFrom = currentEditIndex; moveComposerToDockForSubmit(); - clearResumableTurn(); let truncatedSnapshot = null; if (replaceFrom !== null) { truncatedSnapshot = truncateMessagesFrom(replaceFrom); @@ -1420,10 +1344,6 @@ if (actionButton) { actionButton.addEventListener('click', async function () { - if (canResumeResponse()) { - await startResume(); - return; - } if (!currentTurn) { form.requestSubmit(); return; @@ -1434,13 +1354,11 @@ await requestAbort(turn); if (currentTurn === turn) { markTurnStopped(assistant); - setResumableTurn(turn, assistant); finishTurn(''); } } catch (error) { if (currentTurn === turn && assistant) { markTurnError(assistant, 'The turn could not be stopped.'); - clearResumableTurn(); finishTurn('Stop failed'); } } diff --git a/internal/web/server.go b/internal/web/server.go index 36dbf5e..a2cfdb6 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -28,9 +28,8 @@ import ( ) const ( - sessionCookieName = "pyttechat_session" - csrfHeaderName = "X-CSRF-Token" - resumeContinuationPrompt = "Continue the previous assistant response from where it stopped. Do not restart or mention the interruption." + sessionCookieName = "pyttechat_session" + csrfHeaderName = "X-CSRF-Token" ) //go:embed assets/* assets/vendor/* assets/vendor/katex/* assets/vendor/katex/fonts/* templates/* @@ -236,8 +235,6 @@ func (s *Server) handleTurnRoute(w http.ResponseWriter, r *http.Request) { s.handleTurnEvents(w, r, turnID) case action == "abort" && r.Method == http.MethodPost: s.handleAbortTurn(w, r, turnID) - case action == "resume" && r.Method == http.MethodPost: - s.handleResumeTurn(w, r, turnID) default: http.NotFound(w, r) } @@ -345,72 +342,6 @@ func (s *Server) handleAbortTurn(w http.ResponseWriter, r *http.Request, turnID }) } -func (s *Server) handleResumeTurn(w http.ResponseWriter, r *http.Request, turnID string) { - requestCtx := r.Context() - _, span := observability.StartSpan(requestCtx, "chat.turn.resume.request", observability.ComponentWeb) - defer span.End() - - session, err := s.session(w, r) - if err != nil { - if errors.Is(err, auth.ErrInvalidSession) { - writeJSONError(w, http.StatusUnauthorized, "authentication_required", "authentication required") - return - } - writeJSONError(w, http.StatusInternalServerError, "session_error", "could not create session") - return - } - if !validCSRF(r, session.csrf) { - writeJSONError(w, http.StatusForbidden, "invalid_csrf", "invalid CSRF token") - return - } - - original := session.turn(turnID) - if original == nil { - writeJSONError(w, http.StatusNotFound, "turn_not_found", "turn not found") - return - } - if !original.canResume() { - writeJSONError(w, http.StatusConflict, "turn_not_resumable", "turn cannot be resumed") - return - } - - messages := session.chat.Messages() - if len(messages) == 0 || messages[len(messages)-1].Role != llm.RoleAssistant { - writeJSONError(w, http.StatusConflict, "turn_not_resumable", "turn cannot be resumed") - return - } - turn, err := newResumeTurnJobWithContext(context.WithoutCancel(r.Context()), resumeContinuationPrompt, original.assistantMessageID, messages[len(messages)-1]) - if err != nil { - writeJSONError(w, http.StatusInternalServerError, "turn_error", "could not create turn") - return - } - - session.mu.Lock() - for _, existing := range session.turns { - if !existing.isTerminal() { - session.mu.Unlock() - writeJSONError(w, http.StatusConflict, "turn_in_progress", "turn already in progress") - return - } - } - session.turns[turn.id] = turn - session.mu.Unlock() - - go turn.runResume(session.chat, chat.ResumeOptions{ //nolint:contextcheck // turn jobs use their own cancelable context and outlive the request. - Model: s.model, - ReasoningEffort: s.reasoningEffort, - RenderingInstructions: chat.WebRenderingInstructions(), - TelemetryComponent: observability.ComponentWeb, - ContinuationPrompt: resumeContinuationPrompt, - }) - - writeJSON(w, http.StatusCreated, resumeTurnResponse{ - TurnID: turn.id, - AssistantMessageID: turn.assistantMessageID, - StreamURL: "/chat/turns/" + turn.id + "/events", - }) -} - func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) { if !s.authEnabled() { http.NotFound(w, r) @@ -774,12 +705,6 @@ type createTurnResponse struct { StreamURL string `json:"stream_url"` } -type resumeTurnResponse struct { - TurnID string `json:"turn_id"` - AssistantMessageID string `json:"assistant_message_id"` - StreamURL string `json:"stream_url"` -} - type pageData struct { CSRFToken string ModelLabel string @@ -1111,7 +1036,6 @@ type turnJob struct { userMessageID string assistantMessageID string prompt string - resumeBaseParts []llm.Part ctx context.Context cancel context.CancelFunc @@ -1120,7 +1044,6 @@ type turnJob struct { nextEventID int64 subscribers map[chan streamEvent]struct{} terminal bool - terminalName string abortRequested bool done chan struct{} doneClosed bool @@ -1159,27 +1082,6 @@ func newTurnJobWithContext(ctx context.Context, prompt string) (*turnJob, error) }, nil } -func newResumeTurnJobWithContext(ctx context.Context, prompt, assistantMessageID string, baseAssistant llm.Message) (*turnJob, error) { //nolint:contextcheck // turn jobs store a cancelable context because they outlive the create request. - turnID, err := randomID("turn") - if err != nil { - return nil, err - } - if ctx == nil { - ctx = context.Background() - } - ctx, cancel := context.WithCancel(ctx) - return &turnJob{ - id: turnID, - assistantMessageID: assistantMessageID, - prompt: prompt, - resumeBaseParts: cloneParts(baseAssistant.Parts), - ctx: ctx, - cancel: cancel, - subscribers: map[chan streamEvent]struct{}{}, - done: make(chan struct{}), - }, nil -} - func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { defer j.finish() @@ -1265,107 +1167,10 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { } } -func (j *turnJob) runResume(session *chat.Session, opts chat.ResumeOptions) { - defer j.finish() - - stream, err := session.ResumeLastAssistant(j.ctx, opts) - if err != nil { - j.emitError(err) - return - } - defer stream.Close() - - renderer := markdown.NewRenderer() - var assistantParts []llm.Part - completed := false - for { - event, err := stream.Next() - if errors.Is(err, io.EOF) { - if !completed { - if j.wasAbortRequested() { - j.emitResumeAbortedAfterCommit(stream) - return - } - j.emitError(io.ErrUnexpectedEOF) - } - return - } - if err != nil { - if j.shouldAbort(err) { - j.emitResumeAbortedAfterCommit(stream) - return - } - j.emitError(err) - return - } - - switch event.Type { - case llm.EventTextDelta: - if event.Delta == "" { - continue - } - appendOutputDelta(&assistantParts, llm.PartText, event.Delta) - html, err := renderAssistantBody(mergeAssistantOutputParts(j.resumeBaseParts, assistantParts), renderer) - if err != nil { - log.Printf("markdown resume preview render failed for turn %s: %v", j.id, err) - html = escapedPlainTextHTML(assistantText(mergeAssistantOutputParts(j.resumeBaseParts, assistantParts))) - } - j.emit("preview", htmlEvent{ - TurnID: j.id, - AssistantMessageID: j.assistantMessageID, - HTML: html, - }) - case llm.EventReasoningDelta: - appendOutputDelta(&assistantParts, llm.PartReasoning, event.Delta) - j.emit("reasoning", deltaEvent{ - TurnID: j.id, - AssistantMessageID: j.assistantMessageID, - Delta: event.Delta, - }) - case llm.EventOutputItemDone: - mergeCompletedOutputPart(&assistantParts, event.Part) - case llm.EventCompleted: - completed = true - parts := mergeAssistantOutputParts(j.resumeBaseParts, assistantParts) - html, err := renderAssistantBody(parts, renderer) - if err != nil { - log.Printf("markdown resume final render failed for turn %s: %v", j.id, err) - html = escapedPlainTextHTML(assistantText(parts)) - } - j.emitTerminal("done", doneEvent{ - TurnID: j.id, - AssistantMessageID: j.assistantMessageID, - ResponseID: event.ResponseID, - Usage: event.Usage, - HTML: html, - Statuses: assistantStatuses(parts, -1), - CompletedAt: timeNow().UTC().Format(time.RFC3339), - }) - return - case llm.EventError: - if event.Err != nil { - j.emitError(event.Err) - return - } - } - } -} - func (j *turnJob) shouldAbort(err error) bool { return j.wasAbortRequested() || errors.Is(err, context.Canceled) } -func cloneParts(parts []llm.Part) []llm.Part { - if parts == nil { - return nil - } - out := make([]llm.Part, len(parts)) - for i, part := range parts { - out[i] = part.Clone() - } - return out -} - func appendOutputDelta(parts *[]llm.Part, partType llm.PartType, delta string) { if delta == "" { return @@ -1412,45 +1217,6 @@ func mergeCompletedOutputPart(parts *[]llm.Part, part llm.Part) { *parts = append(*parts, part.Clone()) } -func mergeAssistantOutputParts(base, continuation []llm.Part) []llm.Part { - parts := cloneParts(base) - for _, part := range continuation { - if part.Type == "" { - continue - } - lastIndex := len(parts) - 1 - if lastIndex >= 0 && canMergeAssistantOutputParts(parts[lastIndex], part) { - parts[lastIndex].Text += part.Text - if len(part.Summary) > 0 { - parts[lastIndex].Summary = append(parts[lastIndex].Summary, part.Summary...) - } - if part.ID != "" { - parts[lastIndex].ID = part.ID - } - if part.EncryptedContent != "" { - parts[lastIndex].EncryptedContent = part.EncryptedContent - } - continue - } - parts = append(parts, part.Clone()) - } - return parts -} - -func canMergeAssistantOutputParts(left, right llm.Part) bool { - return (left.Type == llm.PartText || left.Type == llm.PartReasoning) && left.Type == right.Type -} - -func assistantText(parts []llm.Part) string { - var text strings.Builder - for _, part := range parts { - if part.Type == llm.PartText { - text.WriteString(part.Text) - } - } - return text.String() -} - func escapedPlainTextHTML(text string) template.HTML { escaped := template.HTMLEscapeString(text) escaped = strings.ReplaceAll(escaped, "\r\n", "\n") @@ -1482,18 +1248,6 @@ func (j *turnJob) emitAbortedAfterCommit(session *chat.Session, stream *chat.Tur j.emitAborted() } -func (j *turnJob) emitResumeAbortedAfterCommit(stream *chat.TurnStream) { - if stream == nil { - j.emitAborted() - return - } - if err := stream.CommitPartial(); err != nil { - j.emitStreamError() - return - } - j.emitAborted() -} - func (j *turnJob) emitAborted() { j.emitTerminal("aborted", abortedEvent{ TurnID: j.id, @@ -1544,7 +1298,6 @@ func (j *turnJob) emitTerminal(name string, payload any) { event := streamEvent{ID: j.nextEventID, Name: name, Data: data} j.events = append(j.events, event) j.terminal = true - j.terminalName = name for subscriber := range j.subscribers { select { case subscriber <- event: @@ -1639,12 +1392,6 @@ func (j *turnJob) wasAbortRequested() bool { return j.abortRequested } -func (j *turnJob) canResume() bool { - j.mu.Lock() - defer j.mu.Unlock() - return j.terminal && j.terminalName == "aborted" && j.abortRequested -} - type deltaEvent struct { TurnID string `json:"turn_id"` AssistantMessageID string `json:"assistant_message_id"` diff --git a/internal/web/server_test.go b/internal/web/server_test.go index c316415..95619c8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -98,7 +98,7 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `title="Send message"`, `data-action-state="send"`, `data-action-icon="play"`, - `data-action-icon="pause"`, + `data-action-icon="stop"`, `id="composer-end-target"`, `data-composer-end-target`, `id="dirty-dialog"`, @@ -885,7 +885,6 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `data.endActive = 'true'`, `ffwdButton`, `requestNavigation(null, 'end')`, - `resumeTurn`, `handleEditablePromptClick`, `requestNavigation(index, 'end')`, `handleHistoryShortcut`, @@ -896,8 +895,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `ArrowUp`, `ArrowDown`, `data-action-icon`, - `Resume response`, - `Pause response`, + `toggleAttribute('hidden'`, + `Stop response`, + `Stopping response`, `createMessageActions(role)`, `role !== 'assistant'`, `copy.title = 'Copy message'`, @@ -913,6 +913,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `Generating response`, `Response complete`, `Response stopped`, + `Resume response`, + `resumeTurn`, + `/resume`, `article.append(createThinkingStatus());`, } { if strings.Contains(body, unwanted) { @@ -1504,200 +1507,16 @@ func TestAbortedTurnPersistsPartialOutputForFollowUp(t *testing.T) { } } -func TestResumeAbortedTurnContinuesPartialIntoExistingAssistant(t *testing.T) { - llmClient := newControlledClient() - server := httptest.NewServer(NewServer(Options{Client: llmClient})) - defer server.Close() - - client := testHTTPClient(t) - csrfToken := fetchCSRFToken(t, client, server.URL) - turn := createTurn(t, client, server.URL, csrfToken, "hello") - _ = llmClient.waitForContext(t) - - request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+turn.StreamURL, nil) - if err != nil { - t.Fatalf("NewRequest events error = %v", err) - } - response, err := client.Do(request) - if err != nil { - t.Fatalf("GET events error = %v", err) - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(response.Body) - t.Fatalf("GET events status = %d, want 200; body = %q", response.StatusCode, raw) - } - - select { - case llmClient.events <- llm.Event{Type: llm.EventTextDelta, Delta: "partial"}: - case <-time.After(time.Second): - t.Fatalf("timed out sending partial event") - } - reader := bufio.NewReader(response.Body) - preview := decodePreviewFrame(t, readSSEFrame(t, reader)) - if preview.AssistantMessageID != turn.AssistantMessageID || !strings.Contains(preview.HTML, "partial") { - t.Fatalf("preview payload = %#v, want partial output on original assistant message", preview) - } - - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+turn.TurnID+"/abort", nil) - request.Header.Set(csrfHeaderName, csrfToken) - abortResponse, abortBody := do(t, client, request) - defer abortResponse.Body.Close() - if abortResponse.StatusCode != http.StatusOK { - t.Fatalf("abort status = %d, want 200; body = %q", abortResponse.StatusCode, abortBody) - } - if !hasFrame(parseSSE(t, mustReadAllString(t, reader)), "aborted", `"turn_id":"`+turn.TurnID+`"`) { - t.Fatalf("aborted turn did not stream terminal event") - } - - resume := resumeTurn(t, client, server.URL, csrfToken, turn.TurnID) - if resume.TurnID == turn.TurnID || resume.AssistantMessageID != turn.AssistantMessageID { - t.Fatalf("resume response = %#v, want new turn ID and original assistant message ID %q", resume, turn.AssistantMessageID) - } - _ = llmClient.waitForContext(t) - - request, err = http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+resume.StreamURL, nil) - if err != nil { - t.Fatalf("NewRequest resume events error = %v", err) - } - resumeResponse, err := client.Do(request) - if err != nil { - t.Fatalf("GET resume events error = %v", err) - } - defer resumeResponse.Body.Close() - if resumeResponse.StatusCode != http.StatusOK { - raw, _ := io.ReadAll(resumeResponse.Body) - t.Fatalf("GET resume events status = %d, want 200; body = %q", resumeResponse.StatusCode, raw) - } - - select { - case llmClient.events <- llm.Event{Type: llm.EventTextDelta, Delta: " continued"}: - case <-time.After(time.Second): - t.Fatalf("timed out sending resumed event") - } - select { - case llmClient.events <- llm.Event{Type: llm.EventCompleted, ResponseID: "resp_resume"}: - case <-time.After(time.Second): - t.Fatalf("timed out sending resumed completion") - } - - resumeReader := bufio.NewReader(resumeResponse.Body) - resumePreview := decodePreviewFrame(t, readSSEFrame(t, resumeReader)) - if resumePreview.TurnID != resume.TurnID || resumePreview.AssistantMessageID != turn.AssistantMessageID || !strings.Contains(resumePreview.HTML, "partial continued") { - t.Fatalf("resume preview = %#v, want merged output on original assistant message", resumePreview) - } - done := decodeDoneFrame(t, readSSEFrame(t, resumeReader)) - if done.TurnID != resume.TurnID || done.AssistantMessageID != turn.AssistantMessageID || !strings.Contains(done.HTML, "partial continued") { - t.Fatalf("resume done = %#v, want merged output on original assistant message", done) - } - - requests := llmClient.Requests() - if len(requests) != 2 { - t.Fatalf("request count = %d, want original plus resume", len(requests)) - } - got := requests[1].Messages - if len(got) != 3 || got[0].Text() != "hello" || got[1].Text() != "partial" || got[2].Role != llm.RoleUser || !strings.Contains(got[2].Text(), "Continue the previous assistant response") { - t.Fatalf("resume request messages = %#v, want original prompt, partial assistant, hidden continuation prompt", got) - } -} - -func TestResumeRequiresCSRFAbortedTurnAndIdleSession(t *testing.T) { - llmClient := newControlledClient() - server := httptest.NewServer(NewServer(Options{Client: llmClient})) +func TestResumeRouteIsNotSupported(t *testing.T) { + server := httptest.NewServer(NewServer(Options{Client: dummy.NewClient()})) defer server.Close() client := testHTTPClient(t) - csrfToken := fetchCSRFToken(t, client, server.URL) - request := newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/missing/resume", nil) - request.Header.Set(csrfHeaderName, csrfToken) + request := newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/turn_missing/resume", nil) response, body := do(t, client, request) defer response.Body.Close() if response.StatusCode != http.StatusNotFound { - t.Fatalf("resume missing turn status = %d, want 404; body = %q", response.StatusCode, body) - } - - completed := createTurn(t, client, server.URL, csrfToken, "done") - _ = llmClient.waitForContext(t) - select { - case llmClient.events <- llm.Event{Type: llm.EventCompleted, ResponseID: "resp_done"}: - case <-time.After(time.Second): - t.Fatalf("timed out sending completion") - } - response, body = get(t, client, server.URL+completed.StreamURL) - response.Body.Close() - if response.StatusCode != http.StatusOK || !hasFrame(parseSSE(t, body), "done", `"turn_id":"`+completed.TurnID+`"`) { - t.Fatalf("completed events status = %d body = %q, want done event", response.StatusCode, body) - } - - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+completed.TurnID+"/resume", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body = do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusConflict { - t.Fatalf("resume completed status = %d, want 409; body = %q", response.StatusCode, body) - } - - aborted := createTurn(t, client, server.URL, csrfToken, "stop me") - _ = llmClient.waitForContext(t) - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+aborted.TurnID+"/abort", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body = do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("abort status = %d, want 200; body = %q", response.StatusCode, body) - } - - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+aborted.TurnID+"/resume", nil) - response, body = do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusForbidden { - t.Fatalf("resume without csrf status = %d, want 403; body = %q", response.StatusCode, body) - } - - busy := createTurn(t, client, server.URL, csrfToken, "busy") - _ = llmClient.waitForContext(t) - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+aborted.TurnID+"/resume", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body = do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusConflict { - t.Fatalf("resume while busy status = %d, want 409; body = %q", response.StatusCode, body) - } - - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+busy.TurnID+"/abort", nil) - request.Header.Set(csrfHeaderName, csrfToken) - cleanupResponse, cleanupBody := do(t, client, request) - defer cleanupResponse.Body.Close() - if cleanupResponse.StatusCode != http.StatusOK { - t.Fatalf("cleanup abort status = %d, want 200; body = %q", cleanupResponse.StatusCode, cleanupBody) - } -} - -func TestResumeAbortedTurnReportsJobCreationFailure(t *testing.T) { - llmClient := newControlledClient() - server := httptest.NewServer(NewServer(Options{Client: llmClient})) - defer server.Close() - - client := testHTTPClient(t) - csrfToken := fetchCSRFToken(t, client, server.URL) - turn := createTurn(t, client, server.URL, csrfToken, "hello") - _ = llmClient.waitForContext(t) - - request := newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+turn.TurnID+"/abort", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body := do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("abort status = %d, want 200; body = %q", response.StatusCode, body) - } - - withRandomReader(t, &sequenceRandomReader{failAt: 1}) - request = newJSONRequest(t, http.MethodPost, server.URL+"/chat/turns/"+turn.TurnID+"/resume", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body = do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusInternalServerError { - t.Fatalf("resume random failure status = %d, want 500; body = %q", response.StatusCode, body) + t.Fatalf("POST resume status = %d, want 404; body = %q", response.StatusCode, body) } } @@ -2593,25 +2412,6 @@ func TestRenderingHelpersCoverBranchVariants(t *testing.T) { if escaped != "<a>
\nb
\nc" { t.Fatalf("escapedPlainTextHTML = %q, want escaped line breaks", escaped) } - merged := mergeAssistantOutputParts([]llm.Part{ - {Type: llm.PartText, Text: "partial"}, - {Type: llm.PartReasoning, Text: "rough"}, - }, []llm.Part{ - {}, - {Type: llm.PartReasoning, Text: " final", Summary: []string{"summary"}}, - {Type: llm.PartText, Text: " answer"}, - {Type: llm.PartText, Text: " continued"}, - {Type: llm.PartError, Text: "warning"}, - }) - if len(merged) != 4 || merged[1].Text != "rough final" || merged[2].Text != " answer continued" || merged[3].Type != llm.PartError { - t.Fatalf("mergeAssistantOutputParts = %#v, want merged reasoning/text plus error", merged) - } - if got := assistantText(merged); got != "partial answer continued" { - t.Fatalf("assistantText = %q, want text-only concatenation", got) - } - if got := mergeAssistantOutputParts(nil, nil); got != nil { - t.Fatalf("mergeAssistantOutputParts(nil, nil) = %#v, want nil", got) - } html, err := renderAssistantBody([]llm.Part{{Type: llm.PartText, Text: " "}}, NewServer(Options{Client: dummy.NewClient()}).markdown) if err != nil || html != "" { t.Fatalf("renderAssistantBody whitespace = %q, %v; want empty nil", html, err) @@ -2744,190 +2544,6 @@ func TestTurnJobEmitErrorMapsCancellationToAborted(t *testing.T) { } } -func TestResumeTurnJobStreamsMergedPreviewsAndDone(t *testing.T) { - session := chat.NewService(dummy.NewClient( - dummy.Turn{TextChunks: []string{"partial"}}, - dummy.Turn{ReasoningChunks: []string{"thinking"}, TextChunks: []string{" continued"}}, - )).NewSession() - first, err := session.Send(context.Background(), "hello", chat.SendOptions{}) - if err != nil { - t.Fatalf("Send() error = %v, want nil", err) - } - drainChatTurnStream(t, first) - - messages := session.Messages() - turn, err := newResumeTurnJobWithContext(nil, resumeContinuationPrompt, "msg_existing", messages[1]) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - - replay, _, terminal := turn.subscribe(0) - if !terminal { - t.Fatalf("resume turn is not terminal") - } - assertReplayEvents(t, replay, []string{"reasoning", "preview", "done"}) - if !strings.Contains(string(replay[1].Data), "partial") || !strings.Contains(string(replay[1].Data), "continued") || - !strings.Contains(string(replay[2].Data), "partial") || !strings.Contains(string(replay[2].Data), "continued") { - t.Fatalf("resume replay = %#v, want partial and continuation in preview and done", replay) - } - if !strings.Contains(string(replay[0].Data), "thinking") { - t.Fatalf("resume reasoning replay = %#v, want reasoning delta", replay[0]) - } -} - -func TestResumeTurnJobStreamErrorBranches(t *testing.T) { - t.Run("invalid history", func(t *testing.T) { - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.Message{Role: llm.RoleAssistant}) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - session := chat.NewService(dummy.NewClient()).NewSession() - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "stream-error") { - t.Fatalf("replay = %#v terminal=%v, want stream-error", replay, terminal) - } - }) - - t.Run("unexpected eof", func(t *testing.T) { - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.NewTextMessage(llm.RoleAssistant, "partial")) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - session := chat.NewService(webSequenceClient{}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", chat.SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "stream-error") { - t.Fatalf("replay = %#v terminal=%v, want stream-error", replay, terminal) - } - }) - - t.Run("nil event error falls through", func(t *testing.T) { - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.NewTextMessage(llm.RoleAssistant, "partial")) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - session := chat.NewService(webSequenceClient{events: []llm.Event{ - {Type: llm.EventError}, - {Type: llm.EventCompleted, ResponseID: "resp_done"}, - }}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", chat.SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "done") { - t.Fatalf("replay = %#v terminal=%v, want done", replay, terminal) - } - }) - - t.Run("event error with cause", func(t *testing.T) { - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.NewTextMessage(llm.RoleAssistant, "partial")) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - session := chat.NewService(webSequenceClient{events: []llm.Event{ - {Type: llm.EventError, Err: errors.New("event failed")}, - }}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", chat.SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "stream-error") { - t.Fatalf("replay = %#v terminal=%v, want stream-error", replay, terminal) - } - }) -} - -func TestResumeTurnJobCommitsPartialOnAbort(t *testing.T) { - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.NewTextMessage(llm.RoleAssistant, "partial")) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - session := chat.NewService(webSequenceClient{events: []llm.Event{ - {Type: llm.EventTextDelta, Delta: " continued"}, - }}).NewSession() - if err := session.CommitStopped(context.Background(), "hello", chat.SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - stream, err := session.ResumeLastAssistant(context.Background(), chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - if err != nil { - t.Fatalf("ResumeLastAssistant error = %v, want nil", err) - } - if event, nextErr := stream.Next(); nextErr != nil || event.Type != llm.EventTextDelta { - t.Fatalf("resume Next() = %#v, %v; want text delta", event, nextErr) - } - - turn.emitResumeAbortedAfterCommit(stream) - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "aborted") { - t.Fatalf("replay = %#v terminal=%v, want aborted", replay, terminal) - } - if got := session.Messages()[1].Text(); got != " continued" { - t.Fatalf("assistant text after partial resume commit = %q, want committed continuation", got) - } - - turn, err = newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.Message{Role: llm.RoleAssistant}) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext second error = %v, want nil", err) - } - turn.emitResumeAbortedAfterCommit(nil) - replay, _, terminal = turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "aborted") { - t.Fatalf("nil stream replay = %#v terminal=%v, want aborted", replay, terminal) - } -} - -func TestResumeTurnJobAbortCancelsRunningResumeStream(t *testing.T) { - llmClient := newAbortBlockingClient() - defer llmClient.releaseCanceledStream() - session := chat.NewService(llmClient).NewSession() - if err := session.CommitStopped(context.Background(), "hello", chat.SendOptions{}); err != nil { - t.Fatalf("CommitStopped error = %v, want nil", err) - } - turn, err := newResumeTurnJobWithContext(context.Background(), resumeContinuationPrompt, "msg_existing", llm.NewTextMessage(llm.RoleAssistant, "partial")) - if err != nil { - t.Fatalf("newResumeTurnJobWithContext error = %v, want nil", err) - } - - done := make(chan struct{}) - go func() { - turn.runResume(session, chat.ResumeOptions{ContinuationPrompt: resumeContinuationPrompt}) - close(done) - }() - llmClient.waitForStreamStart(t) - abortResult := make(chan bool, 1) - go func() { - abortResult <- turn.abort(context.Background()) - }() - llmClient.waitForCancel(t) - llmClient.releaseCanceledStream() - - select { - case aborted := <-abortResult: - if !aborted { - t.Fatalf("abort returned false, want true") - } - case <-time.After(time.Second): - t.Fatalf("abort did not return") - } - select { - case <-done: - case <-time.After(time.Second): - t.Fatalf("resume turn did not finish") - } - - replay, _, terminal := turn.subscribe(0) - if !terminal || !hasReplayEvent(replay, "aborted") { - t.Fatalf("replay = %#v terminal=%v, want aborted", replay, terminal) - } -} - func TestTurnJobIgnoresEmptyTextDeltas(t *testing.T) { turn := newTestTurnJob(t) session := chat.NewService(webSequenceClient{events: []llm.Event{ @@ -3012,12 +2628,6 @@ type turnResponse struct { StreamURL string `json:"stream_url"` } -type testResumeTurnPayload struct { - TurnID string `json:"turn_id"` - AssistantMessageID string `json:"assistant_message_id"` - StreamURL string `json:"stream_url"` -} - type httpResult struct { response *http.Response body string @@ -3220,27 +2830,6 @@ func createTurnWithPayload(t *testing.T, client *http.Client, baseURL, csrfToken return turn } -func resumeTurn(t *testing.T, client *http.Client, baseURL, csrfToken, turnID string) testResumeTurnPayload { - t.Helper() - - request := newJSONRequest(t, http.MethodPost, baseURL+"/chat/turns/"+turnID+"/resume", nil) - request.Header.Set(csrfHeaderName, csrfToken) - response, body := do(t, client, request) - defer response.Body.Close() - if response.StatusCode != http.StatusCreated { - t.Fatalf("POST resume status = %d, want 201; body = %q", response.StatusCode, body) - } - - var turn testResumeTurnPayload - if err := json.Unmarshal([]byte(body), &turn); err != nil { - t.Fatalf("decode resume response error = %v; body = %q", err, body) - } - if turn.TurnID == "" || turn.AssistantMessageID == "" || turn.StreamURL == "" { - t.Fatalf("resume response = %#v, want turn ID, assistant message ID, and stream URL", turn) - } - return turn -} - func newJSONRequest(t *testing.T, method, url string, payload any) *http.Request { t.Helper() diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 2c07464..bb1d2a2 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -134,11 +134,11 @@

Pyttechat

- Send, pause, or resume + Send or stop From 477ad68882654ca5c6ed5a30b015b75c269e67a2 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 14 Jun 2026 13:18:27 +0200 Subject: [PATCH 09/20] Refine prompt editing UI and persist completions --- internal/chat/service.go | 31 +++- internal/chat/service_test.go | 10 +- internal/llm/llm.go | 12 +- internal/llm/openresponses/client_test.go | 34 ++++ internal/storage/sqlite.go | 67 ++++++-- internal/storage/sqlite_test.go | 87 +++++++++- internal/web/assets/app.css | 84 +++------- internal/web/assets/app.js | 185 ++++++++++++++-------- internal/web/server.go | 23 ++- internal/web/server_test.go | 37 ++++- internal/web/templates/index.html | 38 +++-- 11 files changed, 438 insertions(+), 170 deletions(-) diff --git a/internal/chat/service.go b/internal/chat/service.go index 3a4954b..8b5556e 100644 --- a/internal/chat/service.go +++ b/internal/chat/service.go @@ -71,6 +71,7 @@ type SendOptions struct { RenderingInstructions string TelemetryComponent string ReplaceFrom *int + Now func() time.Time } func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*TurnStream, error) { @@ -94,6 +95,10 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T Effort: effort, } } + now := opts.Now + if now == nil { + now = time.Now + } s.mu.Lock() if s.inFlight { @@ -127,6 +132,7 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T keepMessages: keepMessages, ctx: ctx, startedAt: startedAt, + now: now, }, nil } @@ -171,8 +177,10 @@ type TurnStream struct { keepMessages int ctx context.Context startedAt time.Time + now func() time.Time assistantParts []llm.Part + completedAt time.Time completed bool finalized bool recorded bool @@ -226,6 +234,10 @@ func (s *TurnStream) CommitPartial() error { return s.finalize(true) } +func (s *TurnStream) CompletedAt() time.Time { + return s.completedAt +} + func (s *TurnStream) appendDelta(partType llm.PartType, delta string) { if delta == "" { return @@ -281,8 +293,9 @@ func (s *TurnStream) finalize(allowIncomplete bool) error { } assistant := llm.Message{ - Role: llm.RoleAssistant, - Parts: cloneParts(s.assistantParts), + Role: llm.RoleAssistant, + Parts: cloneParts(s.assistantParts), + CompletedAt: s.completionTime(), } s.session.mu.Lock() @@ -294,6 +307,20 @@ func (s *TurnStream) finalize(allowIncomplete bool) error { return nil } +func (s *TurnStream) completionTime() time.Time { + if !s.completed { + return time.Time{} + } + if s.completedAt.IsZero() { + now := s.now + if now == nil { + now = time.Now + } + s.completedAt = now().UTC() + } + return s.completedAt +} + func (s *TurnStream) abort() { if s.finalized { return diff --git a/internal/chat/service_test.go b/internal/chat/service_test.go index 38674fe..23dd4e7 100644 --- a/internal/chat/service_test.go +++ b/internal/chat/service_test.go @@ -6,12 +6,14 @@ import ( "io" "strings" "testing" + "time" "example.com/llm-chat-web/internal/llm" "example.com/llm-chat-web/internal/llm/dummy" ) func TestSessionSendStreamsAndStoresCompletedTurn(t *testing.T) { + completedAt := time.Date(2026, 6, 14, 12, 34, 56, 0, time.UTC) client := dummy.NewClient(dummy.Turn{ ReasoningChunks: []string{"think", "ing"}, TextChunks: []string{"ans", "wer"}, @@ -24,7 +26,10 @@ func TestSessionSendStreamsAndStoresCompletedTurn(t *testing.T) { }) session := NewService(client).NewSession() - stream, err := session.Send(context.Background(), " hello ", SendOptions{Model: "test-model"}) + stream, err := session.Send(context.Background(), " hello ", SendOptions{ + Model: "test-model", + Now: func() time.Time { return completedAt }, + }) if err != nil { t.Fatalf("Send() error = %v, want nil", err) } @@ -50,6 +55,9 @@ func TestSessionSendStreamsAndStoresCompletedTurn(t *testing.T) { if got := messages[1].Text(); got != "answer" { t.Fatalf("assistant text = %q, want answer", got) } + if !messages[1].CompletedAt.Equal(completedAt) { + t.Fatalf("assistant completed_at = %v, want %v", messages[1].CompletedAt, completedAt) + } reasoning := messages[1].Parts[0] if reasoning.Type != llm.PartReasoning { t.Fatalf("first assistant part type = %q, want reasoning", reasoning.Type) diff --git a/internal/llm/llm.go b/internal/llm/llm.go index 7c3a5b9..bae19b7 100644 --- a/internal/llm/llm.go +++ b/internal/llm/llm.go @@ -1,6 +1,9 @@ package llm -import "context" +import ( + "context" + "time" +) type Role string @@ -45,8 +48,9 @@ func (p Part) Clone() Part { } type Message struct { - Role Role - Parts []Part + Role Role + Parts []Part + CompletedAt time.Time } func NewTextMessage(role Role, text string) Message { @@ -69,7 +73,7 @@ func (m Message) Text() string { } func (m Message) Clone() Message { - clone := Message{Role: m.Role} + clone := Message{Role: m.Role, CompletedAt: m.CompletedAt} if m.Parts != nil { clone.Parts = make([]Part, len(m.Parts)) for i, part := range m.Parts { diff --git a/internal/llm/openresponses/client_test.go b/internal/llm/openresponses/client_test.go index e9a6347..3d8c0de 100644 --- a/internal/llm/openresponses/client_test.go +++ b/internal/llm/openresponses/client_test.go @@ -562,6 +562,40 @@ func TestClientOmitsReasoningByDefault(t *testing.T) { } } +func TestClientDoesNotSendMessageCompletionMetadata(t *testing.T) { + var requestBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("Decode request body error = %v", err) + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, `data: {"type":"response.completed","sequence_number":1,"response":{"id":"resp_1"}}`+"\n\n") + })) + defer server.Close() + + completed := llm.NewTextMessage(llm.RoleAssistant, "prior answer") + completed.CompletedAt = time.Date(2026, 6, 14, 20, 16, 13, 0, time.UTC) + stream, err := NewClient(server.URL).Stream(context.Background(), llm.Request{ + Messages: []llm.Message{ + llm.NewTextMessage(llm.RoleUser, "prior prompt"), + completed, + llm.NewTextMessage(llm.RoleUser, "next prompt"), + }, + }) + if err != nil { + t.Fatalf("Stream() error = %v, want nil", err) + } + drainEvents(t, stream) + + encoded, err := json.Marshal(requestBody) + if err != nil { + t.Fatalf("Marshal request body error = %v, want nil", err) + } + if strings.Contains(string(encoded), "CompletedAt") || strings.Contains(string(encoded), "completed_at") || strings.Contains(string(encoded), "2026-06-14") { + t.Fatalf("request body = %s, did not expect assistant completion metadata", encoded) + } +} + func TestClientMapsPriorReasoningIntoInput(t *testing.T) { var requestBody map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 95c1c72..1cb6da3 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -94,7 +94,7 @@ CREATE TABLE IF NOT EXISTS schema_version ( ); INSERT INTO schema_version (version) -SELECT 1 +SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM schema_version); UPDATE schema_version SET version = 1 WHERE version < 1; @@ -137,12 +137,20 @@ CREATE TABLE IF NOT EXISTS messages ( role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), parts_json TEXT NOT NULL, created_at TEXT NOT NULL, + completed_at TEXT, UNIQUE (conversation_id, sequence) ); CREATE INDEX IF NOT EXISTS messages_conversation_sequence_idx ON messages(conversation_id, sequence); `) + if err != nil { + return err + } + if err := s.ensureMessagesCompletedAt(ctx); err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `UPDATE schema_version SET version = 2 WHERE version < 2;`) return err } @@ -370,7 +378,7 @@ func (s *SQLite) Messages(ctx context.Context, conversationID int64) ([]llm.Mess return nil, ErrInvalidArgument } rows, err := s.db.QueryContext(ctx, ` -SELECT role, parts_json +SELECT role, parts_json, completed_at FROM messages WHERE conversation_id = ? ORDER BY sequence ASC @@ -384,12 +392,19 @@ ORDER BY sequence ASC for rows.Next() { var message llm.Message var partsJSON string - if err := rows.Scan(&message.Role, &partsJSON); err != nil { + var completedAt sql.NullString + if err := rows.Scan(&message.Role, &partsJSON, &completedAt); err != nil { return nil, err } if err := json.Unmarshal([]byte(partsJSON), &message.Parts); err != nil { return nil, err } + if completedAt.Valid && strings.TrimSpace(completedAt.String) != "" { + message.CompletedAt, err = parseTime(completedAt.String) + if err != nil { + return nil, err + } + } messages = append(messages, message.Clone()) } if err := rows.Err(); err != nil { @@ -469,18 +484,18 @@ func insertTurnAtSequence(ctx context.Context, tx *sql.Tx, conversationID int64, } createdAt := formatTime(time.Now().UTC()) if _, err := tx.ExecContext(ctx, ` -INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at) -VALUES (?, ?, ?, ?, ?) -`, conversationID, firstSequence, userMessage.Role, string(userParts), createdAt); err != nil { +INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at, completed_at) +VALUES (?, ?, ?, ?, ?, ?) +`, conversationID, firstSequence, userMessage.Role, string(userParts), createdAt, nil); err != nil { if sqliteIsConstraint(err) { return ErrInvalidArgument } return err } if _, err := tx.ExecContext(ctx, ` -INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at) -VALUES (?, ?, ?, ?, ?) -`, conversationID, firstSequence+1, assistantMessage.Role, string(assistantParts), createdAt); err != nil { +INSERT INTO messages (conversation_id, sequence, role, parts_json, created_at, completed_at) +VALUES (?, ?, ?, ?, ?, ?) +`, conversationID, firstSequence+1, assistantMessage.Role, string(assistantParts), createdAt, completedAtValue(assistantMessage.CompletedAt)); err != nil { if sqliteIsConstraint(err) { return ErrInvalidArgument } @@ -489,6 +504,40 @@ VALUES (?, ?, ?, ?, ?) return nil } +func (s *SQLite) ensureMessagesCompletedAt(ctx context.Context) error { + rows, err := s.db.QueryContext(ctx, `PRAGMA table_info(messages)`) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return err + } + if name == "completed_at" { + return rows.Err() + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = s.db.ExecContext(ctx, `ALTER TABLE messages ADD COLUMN completed_at TEXT`) + return err +} + +func completedAtValue(t time.Time) any { + if t.IsZero() { + return nil + } + return formatTime(t.UTC()) +} + type sqlExecer interface { ExecContext(context.Context, string, ...any) (sql.Result, error) } diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index b039203..2e10ff0 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -26,8 +26,82 @@ func TestSQLiteMigrateIsIdempotent(t *testing.T) { if err := store.db.QueryRowContext(context.Background(), `SELECT version FROM schema_version`).Scan(&version); err != nil { t.Fatalf("schema version query error = %v", err) } - if version != 1 { - t.Fatalf("schema version = %d, want 1", version) + if version != 2 { + t.Fatalf("schema version = %d, want 2", version) + } +} + +func TestSQLiteMigrateAddsCompletedAtToVersionOneMessages(t *testing.T) { + db, openErr := sql.Open("sqlite", ":memory:") + if openErr != nil { + t.Fatalf("sql.Open memory error = %v, want nil", openErr) + } + store := NewSQLiteForDB(db) + ctx := context.Background() + if _, err := store.db.ExecContext(ctx, ` +CREATE TABLE schema_version (version INTEGER NOT NULL); +INSERT INTO schema_version (version) VALUES (1); +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash BLOB NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)), + created_at TEXT NOT NULL +); +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sequence INTEGER NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + parts_json TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (conversation_id, sequence) +); +`); err != nil { + t.Fatalf("create version one schema error = %v, want nil", err) + } + + if err := store.Migrate(ctx); err != nil { + t.Fatalf("Migrate version one schema error = %v, want nil", err) + } + + var version int + if err := store.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version); err != nil { + t.Fatalf("schema version query error = %v", err) + } + if version != 2 { + t.Fatalf("schema version = %d, want 2", version) + } + rows, err := store.db.QueryContext(ctx, `PRAGMA table_info(messages)`) + if err != nil { + t.Fatalf("messages table_info error = %v, want nil", err) + } + defer rows.Close() + hasCompletedAt := false + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + t.Fatalf("scan table_info error = %v, want nil", err) + } + if name == "completed_at" { + hasCompletedAt = true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("table_info rows error = %v, want nil", err) + } + if !hasCompletedAt { + t.Fatalf("messages table missing completed_at column after migration") } } @@ -343,7 +417,8 @@ func TestSQLiteMessagesAreOrderedAndPartsJSONRoundTrips(t *testing.T) { } firstAssistant := llm.Message{ - Role: llm.RoleAssistant, + Role: llm.RoleAssistant, + CompletedAt: time.Date(2026, 6, 14, 10, 11, 12, 0, time.UTC), Parts: []llm.Part{ {Type: llm.PartReasoning, ID: "rs_1", Summary: []string{"thinking"}, EncryptedContent: "encrypted"}, {Type: llm.PartText, Text: "answer one"}, @@ -367,6 +442,12 @@ func TestSQLiteMessagesAreOrderedAndPartsJSONRoundTrips(t *testing.T) { if messages[0].Text() != "first" || messages[1].Text() != "answer one" || messages[2].Text() != "second" || messages[3].Text() != "answer two" { t.Fatalf("messages = %#v, want insertion order", messages) } + if !messages[1].CompletedAt.Equal(firstAssistant.CompletedAt) { + t.Fatalf("first assistant completed_at = %v, want %v", messages[1].CompletedAt, firstAssistant.CompletedAt) + } + if !messages[3].CompletedAt.IsZero() { + t.Fatalf("second assistant completed_at = %v, want zero value", messages[3].CompletedAt) + } reasoning := messages[1].Parts[0] if reasoning.ID != "rs_1" || reasoning.EncryptedContent != "encrypted" || len(reasoning.Summary) != 1 { t.Fatalf("round-tripped reasoning part = %#v, want metadata intact", reasoning) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index ffc4864..3f212a8 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -243,6 +243,11 @@ button { filter 120ms ease; } +.messages[data-dirty-prompt="true"] .message[data-after-active-prompt="true"] { + pointer-events: none; + user-select: none; +} + .auth-shell { display: grid; min-height: 100vh; @@ -286,12 +291,13 @@ button { .message-user[data-active-prompt="true"] { position: sticky; - top: 0.75rem; - bottom: 0.75rem; + top: 0.25rem; + bottom: 0.25rem; z-index: 3; box-shadow: - 0 0.75rem 2rem rgba(0, 0, 0, 0.24), - 0 0 0 var(--pico-outline-width) color-mix(in srgb, var(--pico-background-color) 34%, transparent); + 0 8px 24px rgba(0, 0, 0, 0.14), + 0 2px 8px rgba(0, 0, 0, 0.1), + 0 0 0 1px color-mix(in srgb, var(--pico-background-color) 24%, transparent); } .message-assistant { @@ -851,6 +857,10 @@ button { padding: 0 0 1rem; } +.composer-dock[data-composer-detached="true"] { + display: none; +} + .composer { margin: 0; } @@ -877,8 +887,9 @@ button { .composer-dock[data-end-active="true"] .composer-box { box-shadow: - 0 -0.75rem 2rem rgba(0, 0, 0, 0.16), - 0 0 0 var(--pico-outline-width) color-mix(in srgb, var(--pico-background-color) 28%, transparent); + 0 8px 24px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08), + 0 0 0 1px color-mix(in srgb, var(--pico-background-color) 24%, transparent); } .composer-box:focus-within { @@ -891,32 +902,34 @@ button { .composer-dock .composer-box:focus-within { border-color: var(--pico-background-color); box-shadow: - 0 -0.75rem 2rem rgba(0, 0, 0, 0.16), + 0 8px 24px rgba(0, 0, 0, 0.12), + 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 var(--pico-outline-width) var(--pico-primary-focus); } .composer-end-target { - display: none; width: 100%; max-width: 100%; min-height: 3.45rem; margin: 0; padding: 0.55rem; - border: var(--pico-border-width) solid var(--pico-color); + border: var(--pico-border-width) solid var(--pico-muted-border-color); border-radius: 0.9rem; - background: var(--pico-color); - color: var(--pico-background-color); + background: var(--pico-card-background-color); + color: var(--pico-muted-color); box-shadow: none; } -.composer-dock[data-composer-detached="true"] .composer-end-target { +.composer-end-target:not([hidden]) { display: block; } .composer-end-target:hover, .composer-end-target:focus-visible { + border-color: var(--pico-primary); box-shadow: - 0 -0.45rem 1.5rem rgba(0, 0, 0, 0.12), + 0 8px 24px rgba(0, 0, 0, 0.1), + 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 var(--pico-outline-width) var(--pico-primary-focus); } @@ -1071,51 +1084,6 @@ button { font-size: 0.75rem; } -.dirty-dialog { - width: min(26rem, calc(100vw - 2rem)); - padding: 0; - border: var(--pico-border-width) solid var(--pico-muted-border-color); - border-radius: 0.5rem; -} - -.dirty-dialog::backdrop { - background: rgba(0, 0, 0, 0.28); -} - -.dirty-dialog-form { - display: grid; - gap: 0.75rem; - margin: 0; - padding: 1rem; -} - -.dirty-dialog h2, -.dirty-dialog p { - margin: 0; -} - -.dirty-dialog h2 { - font-size: 1rem; -} - -.dirty-dialog p { - color: var(--pico-muted-color); - font-size: 0.9rem; -} - -.dirty-dialog-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 0.5rem; -} - -.dirty-dialog-actions button { - width: auto; - margin: 0; - white-space: nowrap; -} - .sr-only { position: absolute; width: 1px; diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 394da7f..94be4d2 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -10,10 +10,11 @@ const actionIcons = actionButton ? actionButton.querySelectorAll('[data-action-icon]') : []; const undoButton = document.getElementById('undo-button'); const redoButton = document.getElementById('redo-button'); + const previousButton = document.getElementById('previous-button'); + const nextButton = document.getElementById('next-button'); const ffwdButton = document.getElementById('ffwd-button'); const composerStatus = document.getElementById('composer-status'); const composerEndTarget = document.getElementById('composer-end-target'); - const dirtyDialog = document.getElementById('dirty-dialog'); const themeToggle = document.querySelector('[data-theme-toggle]'); const themeIcons = themeToggle ? themeToggle.querySelectorAll('[data-theme-icon]') : []; @@ -34,7 +35,9 @@ let currentEditIndex = null; let originalPromptValue = ''; let dockPromptValue = ''; - let pendingNavigation = null; + let promptHistory = []; + let promptHistoryIndex = -1; + let applyingPromptHistory = false; let mermaidInitialized = false; let mermaidCurrentTheme = ''; let mermaidIDCounter = 0; @@ -168,6 +171,7 @@ function updatePromptHistoryState() { const activeIndex = currentEditIndex; + const dirtySelectedPrompt = hasDirtySelectedPrompt(); const data = composerDock.dataset; if (activeIndex === null) { data.endActive = 'true'; @@ -179,6 +183,12 @@ if (composerEndTarget) { composerEndTarget.hidden = activeIndex === null; } + const messageData = messages.dataset; + if (dirtySelectedPrompt) { + messageData.dirtyPrompt = 'true'; + } else { + delete messageData.dirtyPrompt; + } messages.querySelectorAll('.message[data-message-index]').forEach(function (article) { const index = messageIndex(article); const data = article.dataset; @@ -200,6 +210,54 @@ return prompt.value !== originalPromptValue; } + function hasDirtySelectedPrompt() { + return currentEditIndex !== null && hasDirtyPrompt(); + } + + function resetPromptHistory(value) { + promptHistory = [value || '']; + promptHistoryIndex = 0; + } + + function recordPromptHistory() { + if (applyingPromptHistory) { + return; + } + const value = prompt.value; + if (promptHistory[promptHistoryIndex] === value) { + return; + } + promptHistory = promptHistory.slice(0, promptHistoryIndex + 1); + promptHistory.push(value); + promptHistoryIndex = promptHistory.length - 1; + } + + function canUndoPromptEdit() { + return !currentTurn && !creatingTurn && promptHistoryIndex > 0; + } + + function canRedoPromptEdit() { + return !currentTurn && !creatingTurn && promptHistoryIndex >= 0 && promptHistoryIndex < promptHistory.length - 1; + } + + function applyPromptHistoryStep(delta) { + const nextIndex = promptHistoryIndex + delta; + if (nextIndex < 0 || nextIndex >= promptHistory.length) { + return false; + } + applyingPromptHistory = true; + promptHistoryIndex = nextIndex; + prompt.value = promptHistory[promptHistoryIndex]; + if (currentEditIndex === null) { + dockPromptValue = prompt.value; + } + syncPromptHeight(); + updatePromptHistoryState(); + updateComposerState(); + applyingPromptHistory = false; + return true; + } + function isNearBottom() { return messages.scrollHeight - messages.scrollTop - messages.clientHeight < nearBottomThreshold; } @@ -218,7 +276,7 @@ } function insertMessage(article) { - messages.insertBefore(article, messagesEnd || null); + messages.insertBefore(article, composerEndTarget || messagesEnd || null); } function clearEmptyState() { @@ -335,7 +393,7 @@ clearEditingArticle(); if (targetIndex === null) { - composerDock.insertBefore(form, composerEndTarget || null); + composerDock.append(form); currentEditIndex = null; prompt.value = dockPromptValue; originalPromptValue = dockPromptValue; @@ -351,6 +409,7 @@ prompt.value = originalPromptValue; } + resetPromptHistory(prompt.value); syncPromptHeight(); updatePromptHistoryState(); updateComposerState(); @@ -362,9 +421,10 @@ function moveComposerToDockForSubmit() { const firstRect = form.getBoundingClientRect(); clearEditingArticle(); - composerDock.insertBefore(form, composerEndTarget || null); + composerDock.append(form); currentEditIndex = null; dockPromptValue = ''; + resetPromptHistory(''); updatePromptHistoryState(); animateComposerFrom(firstRect); } @@ -607,9 +667,19 @@ const actions = assistant.article.querySelector('.message-actions'); assistant.article.insertBefore(timestamp, actions || null); } - const date = new Date(completedAt); timestamp.dateTime = completedAt; - timestamp.textContent = Number.isNaN(date.getTime()) ? completedAt : `Completed ${date.toLocaleString()}`; + timestamp.textContent = completedAtText(completedAt); + } + + function completedAtText(completedAt) { + const date = new Date(completedAt); + return Number.isNaN(date.getTime()) ? completedAt : `Completed ${date.toLocaleString()}`; + } + + function localizeCompletedTimes(root) { + root.querySelectorAll('.message-completed-at[datetime]').forEach(function (timestamp) { + timestamp.textContent = completedAtText(timestamp.getAttribute('datetime') || ''); + }); } function updateComposerState() { @@ -631,17 +701,24 @@ } function updateHistoryButtons(submitting) { - const busy = submitting || Boolean(dirtyDialog && dirtyDialog.open); + const busy = submitting; + const dirtySelectedPrompt = hasDirtySelectedPrompt(); const previous = userMessageBefore(transcriptPosition()); - const canRedo = currentEditIndex !== null; + const next = currentEditIndex === null ? null : userMessageAfter(currentEditIndex); if (undoButton) { - undoButton.disabled = busy || !previous; + undoButton.disabled = busy || !canUndoPromptEdit(); } if (redoButton) { - redoButton.disabled = busy || !canRedo; + redoButton.disabled = busy || !canRedoPromptEdit(); + } + if (previousButton) { + previousButton.disabled = busy || dirtySelectedPrompt || !previous; + } + if (nextButton) { + nextButton.disabled = busy || dirtySelectedPrompt || !next; } if (ffwdButton) { - ffwdButton.disabled = busy || currentEditIndex === null; + ffwdButton.disabled = busy || dirtySelectedPrompt || currentEditIndex === null; } } @@ -882,6 +959,7 @@ } function enhanceMessage(article) { + localizeCompletedTimes(article); const body = article.querySelector('.markdown-body'); if (body) { enhanceCodeBlocks(body); @@ -991,37 +1069,11 @@ moveComposerTo(targetIndex, selection || (targetIndex === null ? 'end' : 'end')); } - function continueNavigationAfterDiscard() { - if (!pendingNavigation) { - return; - } - const target = pendingNavigation; - pendingNavigation = null; - prompt.value = originalPromptValue; - if (currentEditIndex === null) { - dockPromptValue = originalPromptValue; - } - navigateTo(target.index, target.selection); - } - function requestNavigation(targetIndex, selection) { if (currentTurn || creatingTurn) { return; } - if (hasDirtyPrompt()) { - pendingNavigation = { index: targetIndex, selection }; - updateComposerState(); - if (dirtyDialog && typeof dirtyDialog.showModal === 'function') { - dirtyDialog.showModal(); - updateComposerState(); - return; - } - if (window.confirm('Discard unsaved prompt changes?')) { - continueNavigationAfterDiscard(); - } else { - pendingNavigation = null; - } - updateComposerState(); + if (hasDirtySelectedPrompt()) { return; } navigateTo(targetIndex, selection); @@ -1043,7 +1095,7 @@ } function historyNavigationBusy() { - return Boolean(currentTurn) || creatingTurn || Boolean(dirtyDialog && dirtyDialog.open); + return Boolean(currentTurn) || creatingTurn || hasDirtySelectedPrompt(); } function canNavigateBackward() { @@ -1070,14 +1122,14 @@ if (event.isComposing || event.keyCode === 229) { return false; } - if (isUndoShortcut(event) && canNavigateBackward()) { + if (isUndoShortcut(event) && canUndoPromptEdit()) { event.preventDefault(); - navigateBackward(); + applyPromptHistoryStep(-1); return true; } - if (isRedoShortcut(event) && canNavigateForward()) { + if (isRedoShortcut(event) && canRedoPromptEdit()) { event.preventDefault(); - navigateForward(); + applyPromptHistoryStep(1); return true; } return false; @@ -1092,6 +1144,13 @@ return article && messages.contains(article) ? article : null; } + function canSelectPrompt(index) { + if (!hasDirtySelectedPrompt()) { + return true; + } + return index === currentEditIndex; + } + function handleEditablePromptMouseDown(event) { if (!(event.target instanceof Element) || isEditablePromptInteractiveTarget(event.target)) { return; @@ -1116,6 +1175,9 @@ } event.preventDefault(); + if (!canSelectPrompt(index)) { + return true; + } if (currentEditIndex === index) { focusPrompt('end'); return true; @@ -1338,7 +1400,9 @@ if (currentEditIndex === null) { dockPromptValue = prompt.value; } + recordPromptHistory(); syncPromptHeight(); + updatePromptHistoryState(); updateComposerState(); }); @@ -1366,11 +1430,23 @@ } if (undoButton) { - undoButton.addEventListener('click', navigateBackward); + undoButton.addEventListener('click', function () { + applyPromptHistoryStep(-1); + }); } if (redoButton) { - redoButton.addEventListener('click', navigateForward); + redoButton.addEventListener('click', function () { + applyPromptHistoryStep(1); + }); + } + + if (previousButton) { + previousButton.addEventListener('click', navigateBackward); + } + + if (nextButton) { + nextButton.addEventListener('click', navigateForward); } if (ffwdButton) { @@ -1385,22 +1461,6 @@ }); } - if (dirtyDialog) { - dirtyDialog.addEventListener('close', function () { - const action = dirtyDialog.returnValue; - if (action === 'submit') { - pendingNavigation = null; - form.requestSubmit(); - } else if (action === 'discard') { - continueNavigationAfterDiscard(); - } else { - pendingNavigation = null; - } - dirtyDialog.returnValue = ''; - updateComposerState(); - }); - } - document.addEventListener('keydown', function (event) { if (event.defaultPrevented || event.target === prompt) { return; @@ -1500,6 +1560,7 @@ applyThemePreference(); enhanceAllMessages(); + resetPromptHistory(prompt.value); syncPromptHeight(); updatePromptHistoryState(); updateComposerState(); diff --git a/internal/web/server.go b/internal/web/server.go index a2cfdb6..a375b2f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -212,6 +212,7 @@ func (s *Server) handleCreateTurn(w http.ResponseWriter, r *http.Request) { RenderingInstructions: chat.WebRenderingInstructions(), TelemetryComponent: observability.ComponentWeb, ReplaceFrom: request.ReplaceFrom, + Now: timeNow, }) writeJSON(w, http.StatusCreated, createTurnResponse{ @@ -723,12 +724,13 @@ type authPageData struct { } type viewMessage struct { - Index int - Role string - Label string - Text string - HTML template.HTML - Statuses []viewStatus + Index int + Role string + Label string + Text string + HTML template.HTML + Statuses []viewStatus + CompletedAt string } type viewStatus struct { @@ -757,6 +759,9 @@ func viewMessages(messages []llm.Message, renderer assistantRenderer) []viewMess } view.HTML = html view.Statuses = statuses + if !message.CompletedAt.IsZero() { + view.CompletedAt = message.CompletedAt.UTC().Format(time.RFC3339) + } } if view.Text == "" && view.HTML == "" && len(view.Statuses) == 0 { continue @@ -1153,6 +1158,10 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { log.Printf("markdown final render failed for turn %s: %v", j.id, err) html = escapedPlainTextHTML(fullMarkdown.String()) } + completedAt := stream.CompletedAt() + if completedAt.IsZero() { + completedAt = timeNow().UTC() + } j.emitTerminal("done", doneEvent{ TurnID: j.id, AssistantMessageID: j.assistantMessageID, @@ -1160,7 +1169,7 @@ func (j *turnJob) run(session *chat.Session, opts chat.SendOptions) { Usage: event.Usage, HTML: html, Statuses: assistantStatuses(assistantParts, -1), - CompletedAt: timeNow().UTC().Format(time.RFC3339), + CompletedAt: completedAt.UTC().Format(time.RFC3339), }) return } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 95619c8..7173dc8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -89,8 +89,12 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `

`, `id="composer-dock"`, `id="undo-button"`, - `title="Previous prompt"`, + `title="Undo prompt edit"`, `id="redo-button"`, + `title="Redo prompt edit"`, + `id="previous-button"`, + `title="Previous prompt"`, + `id="next-button"`, `title="Next prompt"`, `id="ffwd-button"`, `title="Latest prompt"`, @@ -101,7 +105,8 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `data-action-icon="stop"`, `id="composer-end-target"`, `data-composer-end-target`, - `id="dirty-dialog"`, + `aria-label="Undo prompt edit"`, + `aria-label="Redo prompt edit"`, `aria-label="Previous prompt"`, `aria-label="Next prompt"`, `aria-label="Latest prompt"`, @@ -805,18 +810,19 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `padding-right: 2.75rem;`, `.action-button`, `.history-button`, - `.dirty-dialog`, `.composer-dock .composer-box`, `.composer-dock[data-end-active="true"] .composer-box`, `.composer-end-target`, - `.composer-dock[data-composer-detached="true"] .composer-end-target`, - `background: var(--pico-color);`, + `.composer-end-target:not([hidden])`, + `0 8px 24px`, + `0 2px 8px`, `.message-user[data-editing="true"]`, `.message-user[data-active-prompt="true"]`, `position: sticky;`, - `top: 0.75rem;`, - `bottom: 0.75rem;`, + `top: 0.25rem;`, + `bottom: 0.25rem;`, `.message[data-after-active-prompt="true"]`, + `.messages[data-dirty-prompt="true"] .message[data-after-active-prompt="true"]`, `opacity: 0.56;`, `--icon-button-inverse-color:`, `--icon-button-inverse-hover-color:`, @@ -837,6 +843,8 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `min-height: 1.75rem;`, `.message-user .message-action`, `.message-user .message-actions`, + `.dirty-dialog`, + `.composer-dock[data-composer-detached="true"] .composer-end-target`, } { if strings.Contains(body, unwanted) { t.Fatalf("app CSS = %q, did not expect removed streaming cursor style %q", body, unwanted) @@ -882,7 +890,10 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `updatePromptHistoryState`, `data.activePrompt = 'true'`, `data.afterActivePrompt = 'true'`, + `messageData.dirtyPrompt = 'true'`, `data.endActive = 'true'`, + `previousButton`, + `nextButton`, `ffwdButton`, `requestNavigation(null, 'end')`, `handleEditablePromptClick`, @@ -890,6 +901,10 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `handleHistoryShortcut`, `isUndoShortcut`, `isRedoShortcut`, + `recordPromptHistory`, + `applyPromptHistoryStep`, + `canUndoPromptEdit`, + `canSelectPrompt`, `assistantOutputStarted`, `completeThinkingStatus(assistant.article);`, `ArrowUp`, @@ -917,6 +932,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `resumeTurn`, `/resume`, `article.append(createThinkingStatus());`, + `dirtyDialog`, + `showModal`, + `window.confirm`, } { if strings.Contains(body, unwanted) { t.Fatalf("app JS = %q, did not expect redundant composer status %q", body, unwanted) @@ -1752,6 +1770,8 @@ func TestReplaceFromTruncatesConversationContextForFollowUp(t *testing.T) { } func TestIndexRendersCompletedMessagesAndReusesSessionCookie(t *testing.T) { + completedAt := time.Date(2026, 6, 14, 20, 16, 13, 0, time.UTC) + withTimeNow(t, func() time.Time { return completedAt }) server := httptest.NewServer(NewServer(Options{ Client: dummy.NewClient(dummy.Turn{TextChunks: []string{"**answer**"}}), Model: "gpt-actions", @@ -1791,6 +1811,9 @@ func TestIndexRendersCompletedMessagesAndReusesSessionCookie(t *testing.T) { `data-editable-prompt="true"`, `aria-label="Copy message"`, `title="Copy message"`, + `class="message-completed-at"`, + `datetime="` + completedAt.Format(time.RFC3339) + `"`, + `Completed`, } { if !strings.Contains(body, want) { t.Fatalf("GET / body = %q, want completed message affordance %q", body, want) diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index bb1d2a2..b70dc17 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -77,6 +77,9 @@

Pyttechat

{{.Text}}
{{end}} {{if eq .Role "assistant"}} + {{if .CompletedAt}} + + {{end}}
- + + - -
-

Unsaved prompt changes

-

Submit the edited prompt or discard it before moving.

-
- - - -
-
-
From 5694b3d6d757b0f94b0f70cf05faf1b2319108e3 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 14 Jun 2026 13:55:03 +0200 Subject: [PATCH 10/20] Make end prompt behave like a message --- internal/web/assets/app.css | 50 ++++++++++++++----------------- internal/web/assets/app.js | 15 ++++++++-- internal/web/server_test.go | 19 ++++++++---- internal/web/templates/index.html | 8 +++-- 4 files changed, 53 insertions(+), 39 deletions(-) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 3f212a8..eb8edf4 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -212,7 +212,7 @@ button { height: 100%; min-height: 0; overflow-y: auto; - padding: 1.25rem 0 1.5rem; + padding: 1.25rem 0.75rem 1.5rem; scroll-behavior: smooth; scrollbar-gutter: stable; } @@ -248,6 +248,13 @@ button { user-select: none; } +.messages[data-dirty-prompt="true"] .message-end-target { + opacity: 0.56; + filter: grayscale(0.35); + pointer-events: none; + user-select: none; +} + .auth-shell { display: grid; min-height: 100vh; @@ -295,11 +302,22 @@ button { bottom: 0.25rem; z-index: 3; box-shadow: + 0 0 18px rgba(0, 0, 0, 0.08), 0 8px 24px rgba(0, 0, 0, 0.14), 0 2px 8px rgba(0, 0, 0, 0.1), 0 0 0 1px color-mix(in srgb, var(--pico-background-color) 24%, transparent); } +.message-end-target { + min-height: 3.45rem; + cursor: text; +} + +.message-end-target:focus-visible { + outline: var(--pico-outline-width) solid var(--pico-primary-focus); + outline-offset: 0.125rem; +} + .message-assistant { align-self: stretch; padding-right: 2.75rem; @@ -854,7 +872,7 @@ button { .composer-dock { margin: 0; - padding: 0 0 1rem; + padding: 0 0.75rem 1rem; } .composer-dock[data-composer-detached="true"] { @@ -887,6 +905,7 @@ button { .composer-dock[data-end-active="true"] .composer-box { box-shadow: + 0 0 18px rgba(0, 0, 0, 0.07), 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px color-mix(in srgb, var(--pico-background-color) 24%, transparent); @@ -902,37 +921,12 @@ button { .composer-dock .composer-box:focus-within { border-color: var(--pico-background-color); box-shadow: + 0 0 18px rgba(0, 0, 0, 0.07), 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 var(--pico-outline-width) var(--pico-primary-focus); } -.composer-end-target { - width: 100%; - max-width: 100%; - min-height: 3.45rem; - margin: 0; - padding: 0.55rem; - border: var(--pico-border-width) solid var(--pico-muted-border-color); - border-radius: 0.9rem; - background: var(--pico-card-background-color); - color: var(--pico-muted-color); - box-shadow: none; -} - -.composer-end-target:not([hidden]) { - display: block; -} - -.composer-end-target:hover, -.composer-end-target:focus-visible { - border-color: var(--pico-primary); - box-shadow: - 0 8px 24px rgba(0, 0, 0, 0.1), - 0 2px 8px rgba(0, 0, 0, 0.08), - 0 0 0 var(--pico-outline-width) var(--pico-primary-focus); -} - .composer textarea { max-height: 35vh; min-height: 2.1rem; diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 94be4d2..f3951c5 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -704,7 +704,6 @@ const busy = submitting; const dirtySelectedPrompt = hasDirtySelectedPrompt(); const previous = userMessageBefore(transcriptPosition()); - const next = currentEditIndex === null ? null : userMessageAfter(currentEditIndex); if (undoButton) { undoButton.disabled = busy || !canUndoPromptEdit(); } @@ -715,7 +714,7 @@ previousButton.disabled = busy || dirtySelectedPrompt || !previous; } if (nextButton) { - nextButton.disabled = busy || dirtySelectedPrompt || !next; + nextButton.disabled = busy || dirtySelectedPrompt || currentEditIndex === null; } if (ffwdButton) { ffwdButton.disabled = busy || dirtySelectedPrompt || currentEditIndex === null; @@ -736,7 +735,7 @@ } } - function finishTurn(status) { + function finishTurn(status, options) { clearStreamErrorTimer(); closeSource(); currentTurn = null; @@ -746,6 +745,9 @@ creatingTurn = false; updateComposerState(); setStatus(status || ''); + if (!options || options.focus !== false) { + focusPrompt('end'); + } } function markTurnError(assistant, message) { @@ -1459,6 +1461,13 @@ composerEndTarget.addEventListener('click', function () { requestNavigation(null, 'end'); }); + composerEndTarget.addEventListener('keydown', function (event) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + requestNavigation(null, 'end'); + }); } document.addEventListener('keydown', function (event) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 7173dc8..571e1ab 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -104,7 +104,9 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `data-action-icon="play"`, `data-action-icon="stop"`, `id="composer-end-target"`, + `class="message message-user message-end-target"`, `data-composer-end-target`, + `data-end-prompt="true"`, `aria-label="Undo prompt edit"`, `aria-label="Redo prompt edit"`, `aria-label="Previous prompt"`, @@ -812,8 +814,12 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.history-button`, `.composer-dock .composer-box`, `.composer-dock[data-end-active="true"] .composer-box`, - `.composer-end-target`, - `.composer-end-target:not([hidden])`, + `.message-end-target`, + `.message-end-target:focus-visible`, + `.messages[data-dirty-prompt="true"] .message-end-target`, + `padding: 1.25rem 0.75rem 1.5rem;`, + `padding: 0 0.75rem 1rem;`, + `0 0 18px`, `0 8px 24px`, `0 2px 8px`, `.message-user[data-editing="true"]`, @@ -844,6 +850,7 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.message-user .message-action`, `.message-user .message-actions`, `.dirty-dialog`, + `.composer-end-target`, `.composer-dock[data-composer-detached="true"] .composer-end-target`, } { if strings.Contains(body, unwanted) { @@ -896,6 +903,8 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `nextButton`, `ffwdButton`, `requestNavigation(null, 'end')`, + `requestNavigation(next ? messageIndex(next) : null, 'start')`, + `nextButton.disabled = busy || dirtySelectedPrompt || currentEditIndex === null`, `handleEditablePromptClick`, `requestNavigation(index, 'end')`, `handleHistoryShortcut`, @@ -913,6 +922,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `toggleAttribute('hidden'`, `Stop response`, `Stopping response`, + `focusPrompt('end')`, + `prompt.focus({ preventScroll: true })`, + `composerEndTarget.addEventListener('keydown'`, `createMessageActions(role)`, `role !== 'assistant'`, `copy.title = 'Copy message'`, @@ -921,9 +933,6 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { t.Fatalf("app JS = %q, want streaming UI behavior %q", body, want) } } - if strings.Contains(body, `prompt.focus()`) { - t.Fatalf("app JS = %q, did not expect turn completion to focus composer", body) - } for _, unwanted := range []string{ `Generating response`, `Response complete`, diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index b70dc17..c0c8b95 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -96,9 +96,11 @@

Pyttechat

Start a conversation.
{{end}} - + - + + + + + + + + {{end}} + + +{{end}} @@ -62,21 +124,15 @@

Pyttechat

{{range .Messages}} -
- {{range .Statuses}} -
- - -
- {{end}} {{if eq .Role "assistant"}} +
+ {{range .Statuses}} +
+ + +
+ {{end}}
{{.HTML}}
- {{else if .HTML}} -
{{.HTML}}
- {{else}} -
{{.Text}}
- {{end}} - {{if eq .Role "assistant"}} {{if .CompletedAt}} {{end}} @@ -89,18 +145,16 @@

Pyttechat

Copy message
+ + {{else}} + {{template "prompt-message" .}} {{end}} - {{else}}
Start a conversation.
{{end}} - + {{template "prompt-message" .EndPrompt}}
-
-
-
- - -
-

- - - - - -
-
-
-
From 4e915420fd02cb9df38369b7a5699b7120cf008c Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 14 Jun 2026 21:35:20 +0200 Subject: [PATCH 16/20] Fix inactive end prompt height --- internal/web/server_test.go | 1 + internal/web/templates/index.html | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 0556d0c..5564bc7 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -107,6 +107,7 @@ func TestRootRendersChatPageAndSetsSessionCookie(t *testing.T) { `data-editable-prompt="true"`, `data-editing="true"`, `data-active-prompt="true"`, + `
Latest prompt
`, `
`, `aria-label="Revert prompt changes"`, `aria-label="Previous prompt"`, diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 79f0661..abea5b3 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -1,9 +1,7 @@ {{define "prompt-message"}} {{if .EndPrompt}} -
- {{.Text}} -
+
{{.Text}}
{{else if .HTML}}
{{.HTML}}
{{else}} From 290e1a82473013b7434f2e725106c75e66531caf Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 14 Jun 2026 21:46:17 +0200 Subject: [PATCH 17/20] Stabilize end prompt height --- internal/web/assets/app.css | 5 +++++ internal/web/server_test.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index a687187..d661e9a 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -316,6 +316,11 @@ button { outline-offset: 0.125rem; } +.message-end-target > .message-text { + min-height: 1.5em; + min-height: 1lh; +} + .message-assistant { align-self: stretch; padding-right: 2.75rem; diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 5564bc7..000c0da 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -825,6 +825,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.history-button`, `.message-end-target`, `.message-end-target:focus-visible`, + `.message-end-target > .message-text`, + `min-height: 1.5em;`, + `min-height: 1lh;`, `--shell-inline-padding: clamp(0.75rem, 2vw, 1.5rem);`, `padding: 0 var(--shell-inline-padding);`, `.chat-panel`, From 22e4b5464b0d4b20227bdb10c680b89753e58415 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Sun, 14 Jun 2026 22:32:00 +0200 Subject: [PATCH 18/20] Tweak chat history scrolling and layout --- internal/web/assets/app.css | 24 ++++-- internal/web/assets/app.js | 153 +++++++++++++++++++++++++++++++++--- internal/web/server_test.go | 42 ++++++++-- 3 files changed, 194 insertions(+), 25 deletions(-) diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index d661e9a..73a4356 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -88,12 +88,13 @@ button { .shell { --shell-inline-padding: clamp(0.75rem, 2vw, 1.5rem); + --shell-max-width: 1080px; position: relative; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 0; - width: min(1080px, 100%); + width: min(var(--shell-max-width), 100%); height: 100vh; height: 100svh; margin: 0 auto; @@ -206,17 +207,24 @@ button { .chat-panel { position: relative; min-height: 0; - margin: 0; + width: 100vw; + margin: 0 calc(50% - 50vw); } .messages { + --message-role-offset: clamp(0.375rem, 2vw, 1.5rem); + --messages-inline-padding: max( + var(--shell-inline-padding), + calc((100vw - var(--shell-max-width)) / 2 + var(--shell-inline-padding)) + ); + display: flex; flex-direction: column; gap: 1rem; height: 100%; min-height: 0; overflow-y: auto; - padding: 0.25rem 0.75rem; + padding: 0 var(--messages-inline-padding); scroll-behavior: smooth; scrollbar-gutter: stable; } @@ -285,7 +293,8 @@ button { --message-user-block-padding: 0.875rem; --message-user-inline-padding: 1rem; - align-self: stretch; + align-self: flex-end; + width: calc(100% - var(--message-role-offset)); padding: var(--message-user-block-padding) var(--message-user-inline-padding); border-radius: 0.5rem; background: var(--pico-color); @@ -301,8 +310,8 @@ button { .message-user[data-active-prompt="true"] { position: sticky; - top: 0.0625rem; - bottom: 0.0625rem; + top: 0; + bottom: 0; z-index: 3; box-shadow: 0 0 0 var(--pico-outline-width) var(--pico-primary-focus), @@ -322,7 +331,8 @@ button { } .message-assistant { - align-self: stretch; + align-self: flex-start; + width: calc(100% - var(--message-role-offset)); padding-right: 2.75rem; } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index 7b57dfb..dc14e90 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -235,9 +235,28 @@ } } + function bottomScrollTop() { + return Math.max(0, messages.scrollHeight - messages.clientHeight); + } + + function forceScrollToBottom() { + const wasEndActive = composerEndTarget?.dataset.activePrompt; + if (wasEndActive) { + delete composerEndTarget.dataset.activePrompt; + void composerEndTarget.offsetHeight; + } + messages.scrollTo({ + top: bottomScrollTop(), + behavior: 'auto', + }); + if (wasEndActive) { + composerEndTarget.dataset.activePrompt = wasEndActive; + } + } + function scrollToBottom(force, wasNearBottom) { if (force || wasNearBottom) { - messages.scrollTop = messages.scrollHeight; + forceScrollToBottom(); } updateScrollButton(); } @@ -339,25 +358,119 @@ }); } + function focusPromptNow(selection, preventScroll) { + prompt.focus({ preventScroll: preventScroll !== false }); + const position = selection === 'start' ? 0 : prompt.value.length; + prompt.setSelectionRange(position, position); + } + function focusPrompt(selection) { window.requestAnimationFrame(function () { - prompt.focus({ preventScroll: true }); - const position = selection === 'start' ? 0 : prompt.value.length; - prompt.setSelectionRange(position, position); + focusPromptNow(selection); }); } - function scrollComposerIntoView(targetIndex) { + function preferredScrollBehavior(force) { + if (force || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + return 'auto'; + } + return 'smooth'; + } + + function cssPixels(value) { + const pixels = Number.parseFloat(value); + return Number.isFinite(pixels) ? pixels : 0; + } + + function clampedScrollTop(value) { + return Math.min(bottomScrollTop(), Math.max(0, value)); + } + + function messageGap() { + const gap = Number.parseFloat(window.getComputedStyle(messages).rowGap); + return Number.isFinite(gap) ? gap : 0; + } + + function targetScrollBounds(target) { + const messagesRect = messages.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + let top = targetRect.top - messagesRect.top + messages.scrollTop; + if (target.dataset.activePrompt === 'true') { + const gap = messageGap(); + const previous = target.previousElementSibling; + const next = target.nextElementSibling; + if (previous) { + top = previous.offsetTop + previous.offsetHeight + gap; + } else if (next) { + top = next.offsetTop - target.offsetHeight - gap; + } + } + return { + top, + bottom: top + target.offsetHeight, + }; + } + + function scrollComposerIntoView(targetIndex, direction, force) { const target = targetIndex === null ? composerEndTarget : articleForEditIndex(targetIndex); if (!target) { return; } - const behavior = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'; - target.scrollIntoView({ block: targetIndex === null ? 'nearest' : 'center', behavior }); + if (force && targetIndex === null && direction === 'down') { + forceScrollToBottom(); + updateScrollButton(); + return; + } + const targetStyle = window.getComputedStyle(target); + const topInset = cssPixels(targetStyle.top); + const bottomInset = cssPixels(targetStyle.bottom); + const targetBounds = targetScrollBounds(target); + const visibleTop = messages.scrollTop + topInset; + const visibleBottom = messages.scrollTop + messages.clientHeight - bottomInset; + + if (!force && targetBounds.top >= visibleTop && targetBounds.bottom <= visibleBottom) { + updateScrollButton(); + return; + } + + let nextScrollTop = messages.scrollTop; + if (direction === 'up') { + nextScrollTop = targetBounds.top - topInset; + } else if (direction === 'down') { + nextScrollTop = targetBounds.bottom - messages.clientHeight + bottomInset; + } else if (targetBounds.top < visibleTop) { + nextScrollTop = targetBounds.top - topInset; + } else if (targetBounds.bottom > visibleBottom) { + nextScrollTop = targetBounds.bottom - messages.clientHeight + bottomInset; + } + + nextScrollTop = clampedScrollTop(nextScrollTop); + if (Math.abs(nextScrollTop - messages.scrollTop) >= 1) { + messages.scrollTo({ + top: nextScrollTop, + behavior: preferredScrollBehavior(force), + }); + } + updateScrollButton(); } - function moveComposerTo(targetIndex, selection) { + function focusEndPromptOnLoad() { + const alignEndPrompt = function () { + focusPromptNow('end', false); + scrollComposerIntoView(null, 'down', true); + updateScrollButton(); + }; + window.requestAnimationFrame(alignEndPrompt); + window.setTimeout(alignEndPrompt, 0); + window.setTimeout(alignEndPrompt, 100); + window.addEventListener('load', alignEndPrompt, { once: true }); + } + + function moveComposerTo(targetIndex, selection, direction) { const firstRect = form.getBoundingClientRect(); + if (document.activeElement instanceof HTMLElement && form.contains(document.activeElement)) { + document.activeElement.blur(); + } if (currentEditIndex === null) { endPromptValue = prompt.value; } @@ -382,10 +495,10 @@ } syncPromptHeight(); - updatePromptHistoryState(); updateComposerState(); animateComposerFrom(firstRect); - scrollComposerIntoView(targetIndex); + scrollComposerIntoView(targetIndex, direction || 'nearest', false); + updatePromptHistoryState(); focusPrompt(selection); } @@ -1038,8 +1151,20 @@ } } - function navigateTo(targetIndex, selection) { - moveComposerTo(targetIndex, selection || (targetIndex === null ? 'end' : 'end')); + function navigationDirection(targetIndex) { + const current = transcriptPosition(); + const target = targetIndex === null ? nextMessageIndex : targetIndex; + if (target < current) { + return 'up'; + } + if (target > current) { + return 'down'; + } + return 'nearest'; + } + + function navigateTo(targetIndex, selection, direction) { + moveComposerTo(targetIndex, selection || (targetIndex === null ? 'end' : 'end'), direction); } function requestNavigation(targetIndex, selection) { @@ -1049,7 +1174,7 @@ if (hasDirtySelectedPrompt()) { return; } - navigateTo(targetIndex, selection); + navigateTo(targetIndex, selection, navigationDirection(targetIndex)); } function navigateBackward() { @@ -1123,6 +1248,7 @@ return true; } if (currentEditIndex === index) { + scrollComposerIntoView(index, 'nearest', false); focusPrompt('end'); return true; } @@ -1508,5 +1634,6 @@ syncPromptHeight(); updatePromptHistoryState(); updateComposerState(); + focusEndPromptOnLoad(); updateScrollButton(); })(); diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 000c0da..7bc303d 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -829,10 +829,17 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `min-height: 1.5em;`, `min-height: 1lh;`, `--shell-inline-padding: clamp(0.75rem, 2vw, 1.5rem);`, + `--shell-max-width: 1080px;`, `padding: 0 var(--shell-inline-padding);`, `.chat-panel`, - `margin: 0;`, - `padding: 0.25rem 0.75rem;`, + `width: 100vw;`, + `margin: 0 calc(50% - 50vw);`, + `--message-role-offset: clamp(0.375rem, 2vw, 1.5rem);`, + `--messages-inline-padding: max(`, + `padding: 0 var(--messages-inline-padding);`, + `width: calc(100% - var(--message-role-offset));`, + `align-self: flex-end;`, + `align-self: flex-start;`, `0 0 18px`, `0 0 0 var(--pico-outline-width) var(--pico-primary-focus)`, `0 8px 24px`, @@ -840,8 +847,8 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.message-user[data-editing="true"]`, `.message-user[data-active-prompt="true"]`, `position: sticky;`, - `top: 0.0625rem;`, - `bottom: 0.0625rem;`, + `top: 0;`, + `bottom: 0;`, `--message-user-block-padding: 0.875rem;`, `--message-user-inline-padding: 1rem;`, `padding: var(--message-user-block-padding) var(--message-user-inline-padding);`, @@ -884,6 +891,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `.messages[data-end-active="true"]`, `--composer-dock-reserve`, `.messages[data-dirty-prompt="true"] .message-end-target`, + `padding: 0.25rem 0.75rem;`, + `top: 0.0625rem;`, + `bottom: 0.0625rem;`, } { if strings.Contains(body, unwanted) { t.Fatalf("app CSS = %q, did not expect removed streaming cursor style %q", body, unwanted) @@ -923,6 +933,25 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `const nearBottomThreshold = 32;`, `const wasNearBottom = isNearBottom();`, `scrollToBottom(false, wasNearBottom);`, + `forceScrollToBottom`, + `delete composerEndTarget.dataset.activePrompt;`, + `composerEndTarget.dataset.activePrompt = wasEndActive;`, + `function navigationDirection(targetIndex)`, + `scrollComposerIntoView(targetIndex, direction, force)`, + `bottomScrollTop()`, + `messageGap()`, + `targetScrollBounds(target)`, + `target.dataset.activePrompt === 'true'`, + `targetBounds.top - topInset`, + `targetBounds.bottom - messages.clientHeight + bottomInset`, + `clampedScrollTop(nextScrollTop)`, + `messages.scrollTo({`, + `preferredScrollBehavior(force)`, + `focusEndPromptOnLoad`, + `window.setTimeout(alignEndPrompt, 100);`, + `window.addEventListener('load', alignEndPrompt, { once: true });`, + `scrollComposerIntoView(null, 'down', true);`, + `scrollComposerIntoView(index, 'nearest', false);`, `replace_from`, `markTurnStopped`, `requestNavigation`, @@ -938,6 +967,7 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `article.append(createPromptEditSlot())`, `prompt.closest('.message-user')`, `prompt.style.overflowY = 'hidden';`, + `document.activeElement.blur();`, `revertButton`, `previousButton`, `nextButton`, @@ -958,8 +988,9 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `toggleAttribute('hidden'`, `Stop response`, `Stopping response`, + `focusPromptNow('end', false)`, `focusPrompt('end')`, - `prompt.focus({ preventScroll: true })`, + `preventScroll: preventScroll !== false`, `composerEndTarget.addEventListener('keydown'`, `createMessageActions(role)`, `role !== 'assistant'`, @@ -987,6 +1018,7 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `applyPromptHistoryStep`, `canUndoPromptEdit`, `canRedoPromptEdit`, + `scrollIntoView`, } { if strings.Contains(body, unwanted) { t.Fatalf("app JS = %q, did not expect redundant composer status %q", body, unwanted) From fc642aeeaf7892ce5f9aabaead7ebec1877ca7b1 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 16 Jun 2026 21:14:59 +0200 Subject: [PATCH 19/20] Fix CI lint failures --- internal/storage/sqlite.go | 20 +++++++++---------- internal/web/server_test.go | 38 ------------------------------------- 2 files changed, 10 insertions(+), 48 deletions(-) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 1cb6da3..fcd76a1 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -147,8 +147,8 @@ CREATE INDEX IF NOT EXISTS messages_conversation_sequence_idx if err != nil { return err } - if err := s.ensureMessagesCompletedAt(ctx); err != nil { - return err + if migrateErr := s.ensureMessagesCompletedAt(ctx); migrateErr != nil { + return migrateErr } _, err = s.db.ExecContext(ctx, `UPDATE schema_version SET version = 2 WHERE version < 2;`) return err @@ -393,11 +393,11 @@ ORDER BY sequence ASC var message llm.Message var partsJSON string var completedAt sql.NullString - if err := rows.Scan(&message.Role, &partsJSON, &completedAt); err != nil { - return nil, err + if scanErr := rows.Scan(&message.Role, &partsJSON, &completedAt); scanErr != nil { + return nil, scanErr } - if err := json.Unmarshal([]byte(partsJSON), &message.Parts); err != nil { - return nil, err + if unmarshalErr := json.Unmarshal([]byte(partsJSON), &message.Parts); unmarshalErr != nil { + return nil, unmarshalErr } if completedAt.Valid && strings.TrimSpace(completedAt.String) != "" { message.CompletedAt, err = parseTime(completedAt.String) @@ -517,15 +517,15 @@ func (s *SQLite) ensureMessagesCompletedAt(ctx context.Context) error { var notNull int var defaultValue any var pk int - if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { - return err + if scanErr := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); scanErr != nil { + return scanErr } if name == "completed_at" { return rows.Err() } } - if err := rows.Err(); err != nil { - return err + if rowsErr := rows.Err(); rowsErr != nil { + return rowsErr } _, err = s.db.ExecContext(ctx, `ALTER TABLE messages ADD COLUMN completed_at TEXT`) return err diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 7bc303d..296e8d8 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -3214,44 +3214,6 @@ func readSSEFrame(t *testing.T, reader *bufio.Reader) sseFrame { return frames[0] } -func mustReadAllString(t *testing.T, reader io.Reader) string { - t.Helper() - - body, err := io.ReadAll(reader) - if err != nil { - t.Fatalf("ReadAll error = %v, want nil", err) - } - return string(body) -} - -func drainChatTurnStream(t *testing.T, stream *chat.TurnStream) { - t.Helper() - defer stream.Close() - - for { - _, err := stream.Next() - if errors.Is(err, io.EOF) { - return - } - if err != nil { - t.Fatalf("Next() error = %v, want nil", err) - } - } -} - -func assertReplayEvents(t *testing.T, replay []streamEvent, names []string) { - t.Helper() - - if len(replay) != len(names) { - t.Fatalf("replay event count = %d, want %d: %#v", len(replay), len(names), replay) - } - for i, name := range names { - if replay[i].Name != name { - t.Fatalf("replay[%d] = %q, want %q; replay = %#v", i, replay[i].Name, name, replay) - } - } -} - type controlledClient struct { mu sync.Mutex events chan llm.Event From 45319a94edc393437fdd42eb6cdfaa89ea482d46 Mon Sep 17 00:00:00 2001 From: Johan Carlin Date: Tue, 16 Jun 2026 21:29:35 +0200 Subject: [PATCH 20/20] Address Codex review feedback --- internal/chat/service.go | 23 ++++++++++++++++++----- internal/chat/service_test.go | 11 +++++++---- internal/web/assets/app.js | 19 +++++++++++++++---- internal/web/server_test.go | 6 ++++++ 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/internal/chat/service.go b/internal/chat/service.go index 8b5556e..30e80e8 100644 --- a/internal/chat/service.go +++ b/internal/chat/service.go @@ -112,6 +112,7 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T observability.RecordSpanError(span, err) return nil, err } + replaceTail := opts.ReplaceFrom != nil request.Messages = append(llm.CloneMessages(s.messages[:keepMessages]), userMessage.Clone()) s.inFlight = true s.mu.Unlock() @@ -130,6 +131,7 @@ func (s *Session) Send(ctx context.Context, prompt string, opts SendOptions) (*T stream: stream, userMessage: userMessage, keepMessages: keepMessages, + replaceTail: replaceTail, ctx: ctx, startedAt: startedAt, now: now, @@ -162,7 +164,7 @@ func (s *Session) CommitStopped(ctx context.Context, prompt string, opts SendOpt s.mu.Unlock() return err } - if err := s.replaceTailLocked(ctx, keepMessages, userMessage, llm.Message{Role: llm.RoleAssistant}); err != nil { + if err := s.appendOrReplaceTailLocked(ctx, opts.ReplaceFrom != nil, keepMessages, userMessage, llm.Message{Role: llm.RoleAssistant}); err != nil { s.mu.Unlock() return err } @@ -175,6 +177,7 @@ type TurnStream struct { stream llm.Stream userMessage llm.Message keepMessages int + replaceTail bool ctx context.Context startedAt time.Time now func() time.Time @@ -300,7 +303,7 @@ func (s *TurnStream) finalize(allowIncomplete bool) error { s.session.mu.Lock() defer s.session.mu.Unlock() - if err := s.session.replaceTailLocked(context.Background(), s.keepMessages, s.userMessage, assistant); err != nil { + if err := s.session.appendOrReplaceTailLocked(context.Background(), s.replaceTail, s.keepMessages, s.userMessage, assistant); err != nil { return err } s.finalized = true @@ -381,13 +384,23 @@ func replaceFromIndex(messages []llm.Message, replaceFrom *int) (int, error) { return keepMessages, nil } -func (s *Session) replaceTailLocked(ctx context.Context, keepMessages int, userMessage, assistant llm.Message) error { +func (s *Session) appendOrReplaceTailLocked(ctx context.Context, replaceTail bool, keepMessages int, userMessage, assistant llm.Message) error { if s.store != nil { - if err := s.store.ReplaceTailAndAppendTurn(ctx, s.conversationID, keepMessages, userMessage.Clone(), assistant.Clone()); err != nil { + var err error + if replaceTail { + err = s.store.ReplaceTailAndAppendTurn(ctx, s.conversationID, keepMessages, userMessage.Clone(), assistant.Clone()) + } else { + err = s.store.AppendTurn(ctx, s.conversationID, userMessage.Clone(), assistant.Clone()) + } + if err != nil { return err } } - nextMessages := append(llm.CloneMessages(s.messages[:keepMessages]), userMessage.Clone(), assistant.Clone()) + nextMessages := llm.CloneMessages(s.messages) + if replaceTail { + nextMessages = llm.CloneMessages(s.messages[:keepMessages]) + } + nextMessages = append(nextMessages, userMessage.Clone(), assistant.Clone()) s.messages = nextMessages s.inFlight = false return nil diff --git a/internal/chat/service_test.go b/internal/chat/service_test.go index 23dd4e7..13c7b4b 100644 --- a/internal/chat/service_test.go +++ b/internal/chat/service_test.go @@ -288,11 +288,14 @@ func TestPersistentSessionLoadsHistoryAndAppendsCompletedTurn(t *testing.T) { if requests[0].Messages[0].Text() != "stored prompt" || requests[0].Messages[1].Text() != "stored answer" || requests[0].Messages[2].Text() != "fresh prompt" { t.Fatalf("request messages = %#v, want stored history before fresh prompt", requests[0].Messages) } - if len(store.replaced) != 1 { - t.Fatalf("replace count = %d, want 1", len(store.replaced)) + if len(store.appended) != 1 { + t.Fatalf("append count = %d, want 1", len(store.appended)) + } + if len(store.replaced) != 0 { + t.Fatalf("replace count = %d, want 0", len(store.replaced)) } - if store.replaced[0].conversationID != 42 || store.replaced[0].keepMessages != 2 || store.replaced[0].user.Text() != "fresh prompt" || store.replaced[0].assistant.Text() != "fresh answer" { - t.Fatalf("replaced turn = %#v, want completed fresh turn after stored history in conversation 42", store.replaced[0]) + if store.appended[0].conversationID != 42 || store.appended[0].user.Text() != "fresh prompt" || store.appended[0].assistant.Text() != "fresh answer" { + t.Fatalf("appended turn = %#v, want completed fresh turn in conversation 42", store.appended[0]) } } diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index dc14e90..ade2746 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -411,6 +411,16 @@ }; } + function targetVisualBounds(target) { + const messagesRect = messages.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + const top = targetRect.top - messagesRect.top + messages.scrollTop; + return { + top, + bottom: top + targetRect.height, + }; + } + function scrollComposerIntoView(targetIndex, direction, force) { const target = targetIndex === null ? composerEndTarget : articleForEditIndex(targetIndex); if (!target) { @@ -424,23 +434,24 @@ const targetStyle = window.getComputedStyle(target); const topInset = cssPixels(targetStyle.top); const bottomInset = cssPixels(targetStyle.bottom); - const targetBounds = targetScrollBounds(target); + const visualBounds = targetVisualBounds(target); const visibleTop = messages.scrollTop + topInset; const visibleBottom = messages.scrollTop + messages.clientHeight - bottomInset; - if (!force && targetBounds.top >= visibleTop && targetBounds.bottom <= visibleBottom) { + if (!force && visualBounds.top >= visibleTop && visualBounds.bottom <= visibleBottom) { updateScrollButton(); return; } + const targetBounds = targetScrollBounds(target); let nextScrollTop = messages.scrollTop; if (direction === 'up') { nextScrollTop = targetBounds.top - topInset; } else if (direction === 'down') { nextScrollTop = targetBounds.bottom - messages.clientHeight + bottomInset; - } else if (targetBounds.top < visibleTop) { + } else if (visualBounds.top < visibleTop) { nextScrollTop = targetBounds.top - topInset; - } else if (targetBounds.bottom > visibleBottom) { + } else if (visualBounds.bottom > visibleBottom) { nextScrollTop = targetBounds.bottom - messages.clientHeight + bottomInset; } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 296e8d8..c5683df 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -941,9 +941,15 @@ func TestAssetsRouteAndDefaultNotFound(t *testing.T) { `bottomScrollTop()`, `messageGap()`, `targetScrollBounds(target)`, + `targetVisualBounds(target)`, `target.dataset.activePrompt === 'true'`, + `const visualBounds = targetVisualBounds(target);`, + `visualBounds.top >= visibleTop`, + `visualBounds.bottom <= visibleBottom`, `targetBounds.top - topInset`, `targetBounds.bottom - messages.clientHeight + bottomInset`, + `visualBounds.top < visibleTop`, + `visualBounds.bottom > visibleBottom`, `clampedScrollTop(nextScrollTop)`, `messages.scrollTo({`, `preferredScrollBehavior(force)`,