Compare commits

...

1 Commits

Author SHA1 Message Date
Kyle Carberry 64629eaf15 fix(agents): make chat title nullable to eliminate sidebar flicker
The chat title column is now nullable in PostgreSQL. Chats are created
with title = NULL instead of a truncated fallback string. The async LLM
title generator fires whenever title IS NULL, providing a clean
one-way transition from skeleton to real title.

This eliminates the sidebar flicker caused by:
- The fallback-to-generated title swap on first render
- Query invalidations (refetchOnWindowFocus, reconnect, follow-up
  messages) briefly reverting to stale fallback titles

Backend changes:
- Migration makes chats.title nullable, drops 'New Chat' default
- InsertChat no longer accepts a title parameter
- titleInput() triggers on NULL title instead of comparing fallback
- Removed chatTitleFromMessage, fallbackChatTitle, and
  subagentFallbackChatTitle helper functions

Frontend changes:
- Chat.title is now string | null in TypeScript types
- Sidebar renders a shimmer skeleton for null titles
- Search, aria-labels, and TopBar handle null gracefully
2026-03-10 15:07:03 +00:00
23 changed files with 71 additions and 212 deletions
+4 -7
View File
@@ -167,7 +167,6 @@ type CreateOptions struct {
WorkspaceID uuid.NullUUID
ParentChatID uuid.NullUUID
RootChatID uuid.NullUUID
Title string
ModelConfigID uuid.UUID
SystemPrompt string
InitialUserContent []fantasy.Content
@@ -238,9 +237,6 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
if opts.OwnerID == uuid.Nil {
return database.Chat{}, xerrors.New("owner_id is required")
}
if strings.TrimSpace(opts.Title) == "" {
return database.Chat{}, xerrors.New("title is required")
}
if len(opts.InitialUserContent) == 0 {
return database.Chat{}, xerrors.New("initial user content is required")
}
@@ -253,7 +249,6 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
ParentChatID: opts.ParentChatID,
RootChatID: opts.RootChatID,
LastModelConfigID: opts.ModelConfigID,
Title: opts.Title,
})
if err != nil {
return xerrors.Errorf("insert chat: %w", err)
@@ -1601,11 +1596,13 @@ func (p *Server) publishChatPubsubEvent(chat database.Chat, kind coderdpubsub.Ch
sdkChat := codersdk.Chat{
ID: chat.ID,
OwnerID: chat.OwnerID,
Title: chat.Title,
Status: codersdk.ChatStatus(chat.Status),
CreatedAt: chat.CreatedAt,
UpdatedAt: chat.UpdatedAt,
}
if chat.Title.Valid {
sdkChat.Title = &chat.Title.String
}
if chat.ParentChatID.Valid {
parentChatID := chat.ParentChatID.UUID
sdkChat.ParentChatID = &parentChatID
@@ -2030,7 +2027,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
if p.webpushDispatcher != nil && p.webpushDispatcher.PublicKey() != "" && !chat.ParentChatID.Valid && !wasInterrupted {
if status == database.ChatStatusWaiting || status == database.ChatStatusError {
pushMsg := codersdk.WebpushMessage{
Title: chat.Title,
Title: chat.Title.String,
Body: "Agent has finished running.",
Icon: "/favicon.ico",
Data: map[string]string{"url": fmt.Sprintf("/agents/%s", chat.ID)},
-19
View File
@@ -46,7 +46,6 @@ func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) {
chat, err := replicaA.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "interrupt-me",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -249,7 +248,6 @@ func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "db-transition",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -285,7 +283,6 @@ func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "heartbeat-ownership",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -327,7 +324,6 @@ func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "queue-when-busy",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -378,7 +374,6 @@ func TestSendMessageInterruptBehaviorQueuesAndInterruptsWhenBusy(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "interrupt-when-busy",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -437,7 +432,6 @@ func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "edit-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "original"}},
})
@@ -525,7 +519,6 @@ func TestEditMessageRejectsMissingMessage(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "missing-edited-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -551,7 +544,6 @@ func TestEditMessageRejectsNonUserMessage(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "non-user-edited-message",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -603,7 +595,6 @@ func TestRecoverStaleChatsPeriodically(t *testing.T) {
deadWorkerID := uuid.New()
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "stale-recovery-periodic",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
@@ -648,7 +639,6 @@ func TestRecoverStaleChatsPeriodically(t *testing.T) {
deadWorkerID2 := uuid.New()
chat2, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "stale-recovery-periodic-2",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
@@ -686,7 +676,6 @@ func TestNewReplicaRecoversStaleChatFromDeadReplica(t *testing.T) {
deadReplicaID := uuid.New()
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "orphaned-chat",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
@@ -728,7 +717,6 @@ func TestWaitingChatsAreNotRecoveredAsStale(t *testing.T) {
// by stale recovery.
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "waiting-chat",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
@@ -770,7 +758,6 @@ func TestUpdateChatStatusPersistsLastError(t *testing.T) {
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "error-persisted",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
@@ -825,7 +812,6 @@ func TestSubscribeSnapshotIncludesStatusEvent(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "status-snapshot",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -854,7 +840,6 @@ func TestSubscribeNoPubsubNoDuplicateMessageParts(t *testing.T) {
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "no-dup-parts",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -894,7 +879,6 @@ func TestSubscribeAfterMessageID(t *testing.T) {
// Create a chat — this inserts one initial "user" message.
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "after-id-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "first"}},
})
@@ -1423,7 +1407,6 @@ func TestInterruptChatDoesNotSendWebPushNotification(t *testing.T) {
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "interrupt-no-push",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -1528,7 +1511,6 @@ func TestSuccessfulChatSendsWebPushWithNavigationData(t *testing.T) {
chat, err := server.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "push-nav-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -1612,7 +1594,6 @@ func TestCloseDuringShutdownContextCanceledShouldRetryOnNewReplica(t *testing.T)
chat, err := serverA.CreateChat(ctx, chatd.CreateOptions{
OwnerID: user.ID,
Title: "shutdown-retry",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -35,7 +35,6 @@ func TestStartWorkspace(t *testing.T) {
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "test-no-workspace",
})
require.NoError(t, err)
@@ -78,7 +77,6 @@ func TestStartWorkspace(t *testing.T) {
OwnerID: user.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
LastModelConfigID: modelCfg.ID,
Title: "test-already-running",
})
require.NoError(t, err)
@@ -133,7 +131,6 @@ func TestStartWorkspace(t *testing.T) {
OwnerID: user.ID,
WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true},
LastModelConfigID: modelCfg.ID,
Title: "test-stopped-workspace",
})
require.NoError(t, err)
+5 -47
View File
@@ -89,7 +89,7 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
return toolJSONResponse(map[string]any{
"chat_id": childChat.ID.String(),
"title": childChat.Title,
"title": childChat.Title.String,
"status": string(childChat.Status),
}), nil
},
@@ -128,7 +128,7 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"title": targetChat.Title.String,
"report": report,
"status": string(targetChat.Status),
}), nil
@@ -169,7 +169,7 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"title": targetChat.Title.String,
"status": string(targetChat.Status),
"interrupted": args.Interrupt,
}), nil
@@ -202,7 +202,7 @@ func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.Agent
return toolJSONResponse(map[string]any{
"chat_id": targetChatID.String(),
"title": targetChat.Title,
"title": targetChat.Title.String,
"terminated": true,
"status": string(targetChat.Status),
}), nil
@@ -223,7 +223,7 @@ func (p *Server) createChildSubagentChat(
ctx context.Context,
parent database.Chat,
prompt string,
title string,
_ string,
) (database.Chat, error) {
if parent.ParentChatID.Valid {
return database.Chat{}, xerrors.New("delegated chats cannot create child subagents")
@@ -234,11 +234,6 @@ func (p *Server) createChildSubagentChat(
return database.Chat{}, xerrors.New("prompt is required")
}
title = strings.TrimSpace(title)
if title == "" {
title = subagentFallbackChatTitle(prompt)
}
rootChatID := parent.ID
if parent.RootChatID.Valid {
rootChatID = parent.RootChatID.UUID
@@ -259,7 +254,6 @@ func (p *Server) createChildSubagentChat(
Valid: true,
},
ModelConfigID: parent.LastModelConfigID,
Title: title,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: prompt}},
})
if err != nil {
@@ -494,42 +488,6 @@ func listSubagentDescendants(
return out, nil
}
func subagentFallbackChatTitle(message string) string {
const maxWords = 6
const maxRunes = 80
words := strings.Fields(message)
if len(words) == 0 {
return "New Chat"
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
title += "..."
}
return subagentTruncateRunes(title, maxRunes)
}
func subagentTruncateRunes(value string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxRunes {
return value
}
return string(runes[:maxRunes])
}
func toolJSONResponse(result map[string]any) fantasy.ToolResponse {
data, err := json.Marshal(result)
if err != nil {
+12 -38
View File
@@ -2,6 +2,7 @@ package chatd
import (
"context"
"database/sql"
"strings"
"time"
@@ -92,13 +93,13 @@ func (p *Server) maybeGenerateChatTitle(
)
continue
}
if title == "" || title == chat.Title {
if title == "" {
return
}
_, err = p.db.UpdateChatByID(ctx, database.UpdateChatByIDParams{
ID: chat.ID,
Title: title,
Title: sql.NullString{String: title, Valid: true},
})
if err != nil {
logger.Warn(ctx, "failed to update generated chat title",
@@ -107,7 +108,7 @@ func (p *Server) maybeGenerateChatTitle(
)
return
}
chat.Title = title
chat.Title = sql.NullString{String: title, Valid: true}
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange)
return
}
@@ -167,12 +168,17 @@ func generateTitle(
// titleInput returns the first user message text and whether title
// generation should proceed. It returns false when the chat already
// has assistant/tool replies, has more than one visible user message,
// or the current title doesn't look like a candidate for replacement.
// has a title set, has assistant/tool replies, or no user text is
// available.
func titleInput(
chat database.Chat,
messages []database.ChatMessage,
) (string, bool) {
// Only generate a title if one hasn't been set yet.
if chat.Title.Valid {
return "", false
}
userCount := 0
firstUserText := ""
@@ -200,16 +206,7 @@ func titleInput(
}
}
if userCount != 1 || firstUserText == "" {
return "", false
}
currentTitle := strings.TrimSpace(chat.Title)
if currentTitle == "" {
return firstUserText, true
}
if currentTitle != fallbackChatTitle(firstUserText) {
if userCount == 0 || firstUserText == "" {
return "", false
}
@@ -227,29 +224,6 @@ func normalizeTitleOutput(title string) string {
return truncateRunes(title, 80)
}
func fallbackChatTitle(message string) string {
const maxWords = 6
const maxRunes = 80
words := strings.Fields(message)
if len(words) == 0 {
return "New Chat"
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
title += "…"
}
return truncateRunes(title, maxRunes)
}
// contentBlocksToText concatenates the text parts of content blocks
// into a single space-separated string.
func contentBlocksToText(content []fantasy.Content) string {
+4 -37
View File
@@ -262,7 +262,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
contentBlocks, contentFileIDs, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
contentBlocks, contentFileIDs, _, inputError := createChatInputFromRequest(ctx, api.Database, req)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
return
@@ -274,8 +274,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
return
}
title := chatTitleFromMessage(titleSource)
if api.chatDaemon == nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Chat processor is unavailable.",
@@ -293,7 +291,6 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
chat, err := api.chatDaemon.CreateChat(ctx, chatd.CreateOptions{
OwnerID: apiKey.UserID,
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: api.resolvedChatSystemPrompt(ctx),
InitialUserContent: contentBlocks,
@@ -2586,49 +2583,19 @@ func createChatInputFromParts(
return content, fileIDs, titleSource, nil
}
func chatTitleFromMessage(message string) string {
const maxWords = 6
const maxRunes = 80
words := strings.Fields(message)
if len(words) == 0 {
return "New Chat"
}
truncated := false
if len(words) > maxWords {
words = words[:maxWords]
truncated = true
}
title := strings.Join(words, " ")
if truncated {
title += "…"
}
return truncateRunes(title, maxRunes)
}
func truncateRunes(value string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= maxLen {
return value
}
return string(runes[:maxLen])
}
func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.Chat {
chat := codersdk.Chat{
ID: c.ID,
OwnerID: c.OwnerID,
LastModelConfigID: c.LastModelConfigID,
Title: c.Title,
Status: codersdk.ChatStatus(c.Status),
Archived: c.Archived,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.Title.Valid {
chat.Title = &c.Title.String
}
if c.LastError.Valid {
chat.LastError = &c.LastError.String
}
+11 -21
View File
@@ -75,7 +75,7 @@ func TestPostChats(t *testing.T) {
require.NotEqual(t, uuid.Nil, chat.ID)
require.Equal(t, user.UserID, chat.OwnerID)
require.Equal(t, modelConfig.ID, chat.LastModelConfigID)
require.Equal(t, "hello from chats route tests", chat.Title)
require.Nil(t, chat.Title)
require.Equal(t, codersdk.ChatStatusPending, chat.Status)
require.NotZero(t, chat.CreatedAt)
require.NotZero(t, chat.UpdatedAt)
@@ -321,7 +321,6 @@ func TestListChats(t *testing.T) {
memberDBChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: member.ID,
LastModelConfigID: modelConfig.ID,
Title: "member chat only",
})
require.NoError(t, err)
@@ -351,8 +350,8 @@ func TestListChats(t *testing.T) {
require.Contains(t, chatsByID, firstChatA.ID)
require.Contains(t, chatsByID, firstChatB.ID)
require.NotContains(t, chatsByID, memberDBChat.ID)
require.Equal(t, "first owner chat", chatsByID[firstChatA.ID].Title)
require.Equal(t, "second owner chat", chatsByID[firstChatB.ID].Title)
require.Nil(t, chatsByID[firstChatA.ID].Title)
require.Nil(t, chatsByID[firstChatB.ID].Title)
for i := 1; i < len(chats); i++ {
require.False(t, chats[i-1].UpdatedAt.Before(chats[i].UpdatedAt))
@@ -369,7 +368,7 @@ func TestListChats(t *testing.T) {
require.Len(t, memberChats, 1)
require.Equal(t, memberDBChat.ID, memberChats[0].ID)
require.Equal(t, member.ID, memberChats[0].OwnerID)
require.Equal(t, "member chat only", memberChats[0].Title)
require.Nil(t, memberChats[0].Title)
require.NotNil(t, memberChats[0].RootChatID)
require.Equal(t, memberChats[0].ID, *memberChats[0].RootChatID)
require.NotNil(t, memberChats[0].DiffStatus)
@@ -1196,7 +1195,7 @@ func TestGetChat(t *testing.T) {
require.Equal(t, createdChat.ID, chatWithMessages.Chat.ID)
require.Equal(t, firstUser.UserID, chatWithMessages.Chat.OwnerID)
require.Equal(t, modelConfig.ID, chatWithMessages.Chat.LastModelConfigID)
require.Equal(t, "get chat route payload", chatWithMessages.Chat.Title)
require.Nil(t, chatWithMessages.Chat.Title)
require.NotZero(t, chatWithMessages.Chat.CreatedAt)
require.NotZero(t, chatWithMessages.Chat.UpdatedAt)
require.NotEmpty(t, chatWithMessages.Messages)
@@ -1337,7 +1336,6 @@ func TestArchiveChat(t *testing.T) {
child1, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child 1",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
@@ -1346,7 +1344,6 @@ func TestArchiveChat(t *testing.T) {
child2, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "child 2",
ParentChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
RootChatID: uuid.NullUUID{UUID: parentChat.ID, Valid: true},
})
@@ -1971,9 +1968,9 @@ func TestChatMessageWithFileReferences(t *testing.T) {
require.NoError(t, err)
require.NotEqual(t, uuid.Nil, chat.ID)
// Title is derived from the text parts. For file-references
// the formatted text becomes the title source.
require.NotEmpty(t, chat.Title)
// Title is not set on creation; it is populated
// asynchronously by the chat daemon.
require.Nil(t, chat.Title)
})
}
@@ -2108,8 +2105,9 @@ func TestChatMessageWithFiles(t *testing.T) {
})
require.NoError(t, err)
// With no text, chatTitleFromMessage("") returns "New Chat".
require.Equal(t, "New Chat", chat.Title)
// Title is not set on creation; it is populated
// asynchronously by the chat daemon.
require.Nil(t, chat.Title)
})
t.Run("InvalidFileID", func(t *testing.T) {
@@ -2476,7 +2474,6 @@ func TestInterruptChat(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "interrupt route test",
})
require.NoError(t, err)
@@ -2544,7 +2541,6 @@ func TestGetChatDiffStatus(t *testing.T) {
noCachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "get diff status route no cache",
})
require.NoError(t, err)
@@ -2563,7 +2559,6 @@ func TestGetChatDiffStatus(t *testing.T) {
cachedStatusChat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "get diff status route cached",
})
require.NoError(t, err)
@@ -2666,7 +2661,6 @@ func TestGetChatDiffContents(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "diff contents with cached repository reference",
})
require.NoError(t, err)
@@ -2761,7 +2755,6 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "delete queued message route test",
})
require.NoError(t, err)
@@ -2808,7 +2801,6 @@ func TestDeleteChatQueuedMessage(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "delete queued invalid id",
})
require.NoError(t, err)
@@ -2842,7 +2834,6 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "promote queued message route test",
})
require.NoError(t, err)
@@ -2907,7 +2898,6 @@ func TestPromoteChatQueuedMessage(t *testing.T) {
chat, err := db.InsertChat(dbauthz.AsSystemRestricted(ctx), database.InsertChatParams{
OwnerID: user.UserID,
LastModelConfigID: modelConfig.ID,
Title: "promote queued invalid id",
})
require.NoError(t, err)
+1 -1
View File
@@ -648,7 +648,7 @@ func (s *MethodTestSuite) TestChats() {
chat := testutil.Fake(s.T(), faker, database.Chat{})
arg := database.UpdateChatByIDParams{
ID: chat.ID,
Title: "Updated title",
Title: sql.NullString{String: "Updated title", Valid: true},
}
dbm.EXPECT().GetChatByID(gomock.Any(), chat.ID).Return(chat, nil).AnyTimes()
dbm.EXPECT().UpdateChatByID(gomock.Any(), arg).Return(chat, nil).AnyTimes()
+1 -1
View File
@@ -1283,7 +1283,7 @@ CREATE TABLE chats (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
workspace_id uuid,
title text DEFAULT 'New Chat'::text NOT NULL,
title text,
status chat_status DEFAULT 'waiting'::chat_status NOT NULL,
worker_id uuid,
started_at timestamp with time zone,
@@ -0,0 +1,3 @@
UPDATE chats SET title = 'New Chat' WHERE title IS NULL;
ALTER TABLE chats ALTER COLUMN title SET NOT NULL;
ALTER TABLE chats ALTER COLUMN title SET DEFAULT 'New Chat';
@@ -0,0 +1,2 @@
ALTER TABLE chats ALTER COLUMN title DROP NOT NULL;
ALTER TABLE chats ALTER COLUMN title DROP DEFAULT;
+1 -1
View File
@@ -3896,7 +3896,7 @@ type Chat struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
Title string `db:"title" json:"title"`
Title sql.NullString `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
-1
View File
@@ -8957,7 +8957,6 @@ func TestGetChatMessagesForPromptByChatID(t *testing.T) {
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
LastModelConfigID: modelCfg.ID,
Title: "test-chat-" + uuid.NewString(),
})
require.NoError(t, err)
return chat
+4 -8
View File
@@ -3593,15 +3593,13 @@ INSERT INTO chats (
workspace_id,
parent_chat_id,
root_chat_id,
last_model_config_id,
title
last_model_config_id
) VALUES (
$1::uuid,
$2::uuid,
$3::uuid,
$4::uuid,
$5::uuid,
$6::text
$5::uuid
)
RETURNING
id, owner_id, workspace_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
@@ -3613,7 +3611,6 @@ type InsertChatParams struct {
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Title string `db:"title" json:"title"`
}
func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat, error) {
@@ -3623,7 +3620,6 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
arg.ParentChatID,
arg.RootChatID,
arg.LastModelConfigID,
arg.Title,
)
var i Chat
err := row.Scan(
@@ -3910,8 +3906,8 @@ RETURNING
`
type UpdateChatByIDParams struct {
Title string `db:"title" json:"title"`
ID uuid.UUID `db:"id" json:"id"`
Title sql.NullString `db:"title" json:"title"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParams) (Chat, error) {
+3 -5
View File
@@ -167,15 +167,13 @@ INSERT INTO chats (
workspace_id,
parent_chat_id,
root_chat_id,
last_model_config_id,
title
last_model_config_id
) VALUES (
@owner_id::uuid,
sqlc.narg('workspace_id')::uuid,
sqlc.narg('parent_chat_id')::uuid,
sqlc.narg('root_chat_id')::uuid,
@last_model_config_id::uuid,
@title::text
@last_model_config_id::uuid
)
RETURNING
*;
@@ -237,7 +235,7 @@ RETURNING
UPDATE
chats
SET
title = @title::text,
title = sqlc.narg('title')::text,
updated_at = NOW()
WHERE
id = @id::uuid
-1
View File
@@ -67,7 +67,6 @@ func TestChatParam(t *testing.T) {
ParentChatID: uuid.NullUUID{},
RootChatID: uuid.NullUUID{},
LastModelConfigID: modelConfig.ID,
Title: "Test chat",
})
require.NoError(t, err)
+1 -1
View File
@@ -36,7 +36,7 @@ type Chat struct {
ParentChatID *uuid.UUID `json:"parent_chat_id,omitempty" format:"uuid"`
RootChatID *uuid.UUID `json:"root_chat_id,omitempty" format:"uuid"`
LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"`
Title string `json:"title"`
Title *string `json:"title"`
Status ChatStatus `json:"status"`
LastError *string `json:"last_error"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
-9
View File
@@ -150,7 +150,6 @@ func TestSubscribeRelayReconnectsOnDrop(t *testing.T) {
// Create a chat and mark it as running on a remote worker.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "relay-reconnect",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -244,7 +243,6 @@ func TestSubscribeRelayAsyncDoesNotBlock(t *testing.T) {
// Create a chat in pending status.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "relay-async-nonblock",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -351,7 +349,6 @@ func TestSubscribeRelaySnapshotDelivered(t *testing.T) {
// Create a chat already running on a remote worker.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "relay-snapshot",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -464,7 +461,6 @@ func TestSubscribeRelayStaleDialDiscardedAfterInterrupt(t *testing.T) {
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "stale-dial-test",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -627,7 +623,6 @@ func TestSubscribeCancelDuringInFlightDial(t *testing.T) {
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "cancel-inflight-dial",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -725,7 +720,6 @@ func TestSubscribeRelayRunningToRunningSwitch(t *testing.T) {
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "running-to-running",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -847,7 +841,6 @@ func TestSubscribeRelayFailedDialRetries(t *testing.T) {
// synchronous relay.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "failed-dial-retry",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -957,7 +950,6 @@ func TestSubscribeRunningLocalWorkerClosesRelay(t *testing.T) {
// opens a synchronous relay.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "local-worker-closes-relay",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
@@ -1065,7 +1057,6 @@ func TestSubscribeRelayMultipleReconnects(t *testing.T) {
// Subscribe opens a synchronous relay immediately.
chat, err := subscriber.CreateChat(ctx, osschatd.CreateOptions{
OwnerID: user.ID,
Title: "multiple-reconnects",
ModelConfigID: model.ID,
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
})
+1 -1
View File
@@ -1059,7 +1059,7 @@ export interface Chat {
readonly parent_chat_id?: string;
readonly root_chat_id?: string;
readonly last_model_config_id: string;
readonly title: string;
readonly title: string | null;
readonly status: ChatStatus;
readonly last_error: string | null;
readonly diff_status?: ChatDiffStatus;
+1 -1
View File
@@ -957,7 +957,7 @@ const AgentDetail: FC = () => {
inputValueRef,
});
const chatTitle = chatQuery.data?.chat?.title;
const chatTitle = chatQuery.data?.chat?.title ?? undefined;
const titleElement = (
<title>
@@ -107,7 +107,9 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
className="h-auto max-w-[16rem] rounded-sm px-1 py-0.5 text-xs text-content-secondary shadow-none hover:bg-transparent hover:text-content-primary"
onClick={() => onOpenParentChat(parentChat.id)}
>
<span className="truncate">{parentChat.title}</span>
<span className="truncate">
{parentChat.title ?? "Untitled"}
</span>
</Button>
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary/70" />
</>
+5 -3
View File
@@ -215,7 +215,7 @@ const collectVisibleChatIDs = ({
}
const matchedChatIDs = chats
.filter((chat) => chat.title.toLowerCase().includes(search))
.filter((chat) => (chat.title ?? "").toLowerCase().includes(search))
.map((chat) => chat.id);
if (matchedChatIDs.length === 0) {
return new Set<string>();
@@ -413,7 +413,9 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
isActive && "font-medium",
)}
>
{chat.title}
{chat.title ?? (
<span className="inline-block h-3.5 w-24 animate-pulse rounded bg-surface-tertiary" />
)}
</span>
</div>
<div className="flex min-w-0 items-center gap-1.5">
@@ -460,7 +462,7 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
size="icon"
variant="subtle"
className="absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Open actions for ${chat.title}`}
aria-label={`Open actions for ${chat.title ?? "Untitled"}`}
>
<EllipsisIcon className="h-3.5 w-3.5" />
</Button>
+9 -6
View File
@@ -44,12 +44,15 @@ self.addEventListener("push", (event) => {
return;
}
}
return self.registration.showNotification(payload.title, {
body: payload.body || "",
icon: payload.icon || "/favicon.ico",
data: payload.data,
tag: payload.tag,
});
return self.registration.showNotification(
payload.title ?? "New notification",
{
body: payload.body || "",
icon: payload.icon || "/favicon.ico",
data: payload.data,
tag: payload.tag,
},
);
}),
);
});