mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-03 12:30:13 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b7f4cd1b | ||
|
|
2cb94e8e3a |
@@ -1138,6 +1138,13 @@ export class ApiClient {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,45 @@ export function useMarkChatSessionRead() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a chat session. Optimistically swaps the title in the cached
|
||||
* list so the dropdown reflects the new label immediately; rolls back on
|
||||
* error. The matching `chat:session_updated` WS event keeps other
|
||||
* tabs/devices in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useUpdateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { sessionId: string; title: string }) => {
|
||||
logger.info("updateChatSession.start", {
|
||||
sessionId: data.sessionId,
|
||||
titleLength: data.title.length,
|
||||
});
|
||||
return api.updateChatSession(data.sessionId, { title: data.title });
|
||||
},
|
||||
onMutate: async ({ sessionId, title }) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
|
||||
const patch = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
|
||||
|
||||
return { prevSessions };
|
||||
},
|
||||
onError: (err, vars, ctx) => {
|
||||
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from the
|
||||
* sessions list so the dropdown updates instantly; rolls back on error.
|
||||
|
||||
@@ -259,6 +259,7 @@ export function useRealtimeSync(
|
||||
"daemon:heartbeat",
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
|
||||
"chat:session_updated",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
@@ -724,6 +725,33 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// chat:session_updated fires after the creator renames a session in
|
||||
// any tab/device. Patch the cached row inline so the dropdown reflects
|
||||
// the new title without a full sessions-list refetch.
|
||||
const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => {
|
||||
const payload = p as {
|
||||
chat_session_id: string;
|
||||
title?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
chatWsLogger.info("chat:session_updated (global)", payload);
|
||||
const id = getCurrentWsId();
|
||||
if (!id) return;
|
||||
const patch = (
|
||||
old?: { id: string; title: string; updated_at: string }[],
|
||||
) =>
|
||||
old?.map((s) =>
|
||||
s.id === payload.chat_session_id
|
||||
? {
|
||||
...s,
|
||||
title: payload.title ?? s.title,
|
||||
updated_at: payload.updated_at ?? s.updated_at,
|
||||
}
|
||||
: s,
|
||||
);
|
||||
qc.setQueryData(chatKeys.sessions(id), patch);
|
||||
});
|
||||
|
||||
// chat:session_deleted fires after a hard delete. The originating tab has
|
||||
// already optimistically dropped the row via useDeleteChatSession; this
|
||||
// handler keeps OTHER tabs/devices in sync and also clears the active
|
||||
@@ -784,6 +812,7 @@ export function useRealtimeSync(
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
unsubChatSessionDeleted();
|
||||
unsubChatSessionUpdated();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "chat:session_updated"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useCreateChatSession,
|
||||
useDeleteChatSession,
|
||||
useMarkChatSessionRead,
|
||||
useUpdateChatSession,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
@@ -734,7 +735,12 @@ function SessionDropdown({
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
|
||||
// Inline rename: only one row can be in edit mode at a time. We track the
|
||||
// session id (not the full session) so a stale closure can't overwrite a
|
||||
// newer rename pulled in via WS.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const deleteSession = useDeleteChatSession();
|
||||
const updateSession = useUpdateChatSession();
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const formatTimeAgo = useFormatTimeAgo();
|
||||
|
||||
@@ -773,14 +779,35 @@ function SessionDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitRename = (sessionId: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const current = sessions.find((s) => s.id === sessionId);
|
||||
setRenamingId(null);
|
||||
// No-op submits (unchanged or blank) skip the network round-trip — the
|
||||
// server would reject a blank title anyway, and an unchanged title would
|
||||
// just bump updated_at for no user-visible reason.
|
||||
if (!trimmed || trimmed === current?.title) return;
|
||||
updateSession.mutate({ sessionId, title: trimmed });
|
||||
};
|
||||
|
||||
const renderRow = (session: ChatSession) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
const isRunning = inFlightSessionIds.has(session.id);
|
||||
const isRenaming = renamingId === session.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
// While renaming we don't want a row click to select the session
|
||||
// OR close the menu — the user is editing text, not navigating.
|
||||
// closeOnClick=false keeps the dropdown open across input clicks
|
||||
// / button clicks inside the row; the normal "click row → switch
|
||||
// session → close menu" flow is unchanged when isRenaming=false.
|
||||
closeOnClick={!isRenaming}
|
||||
onClick={() => {
|
||||
if (isRenaming) return;
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className="group flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
@@ -795,45 +822,84 @@ function SessionDropdown({
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/70">
|
||||
{formatTimeAgo(session.updated_at)}
|
||||
</div>
|
||||
{isRenaming ? (
|
||||
<SessionRenameInput
|
||||
initialValue={session.title ?? ""}
|
||||
onSubmit={(value) => handleSubmitRename(session.id, value)}
|
||||
onCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="truncate text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/70">
|
||||
{formatTimeAgo(session.updated_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Right-edge status pip: in-flight wins over unread because
|
||||
* "still working" is more actionable than "has reply" — and
|
||||
* the two rarely coexist in practice (the unread flag fires
|
||||
* on chat_message write, by which point the task has just
|
||||
* finished). Same pip shape as unread for visual rhythm,
|
||||
* amber + pulse to read as activity. */}
|
||||
{isRunning ? (
|
||||
* amber + pulse to read as activity.
|
||||
*
|
||||
* Hidden while renaming so the inline input has room to
|
||||
* breathe and trailing pips don't visually trail off-screen
|
||||
* next to the editor caret. */}
|
||||
{!isRenaming && isRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.running)}
|
||||
title={t(($) => $.window.running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : session.has_unread ? (
|
||||
) : !isRenaming && session.has_unread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.unread)}
|
||||
title={t(($) => $.window.unread)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
{!isRenaming && isCurrent && (
|
||||
<Check className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
// preventDefault is what tells Base UI's Menu.Item to skip
|
||||
// its close-on-click; stopPropagation prevents the row's
|
||||
// onClick from also firing (which would switch sessions).
|
||||
// onPointerDown is stopped too so the menu's typeahead /
|
||||
// focus tracking doesn't pre-empt the click.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setRenamingId(session.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_rename_aria)}
|
||||
title={t(($) => $.session_history.row_rename_aria)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -950,6 +1016,86 @@ function SessionDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline editor for a session title. Mounts focused with the existing
|
||||
* title pre-selected so the user can either replace it outright or arrow
|
||||
* into the existing text. Enter commits, Escape cancels, a real click
|
||||
* outside the input also commits.
|
||||
*
|
||||
* We do NOT commit on the input's `blur` event: Base UI's Menu uses
|
||||
* focus-follows-cursor (hovering a sibling row drags DOM focus there),
|
||||
* so a blur handler would fire on every mouse-move and "save" the user's
|
||||
* half-typed title without them clicking anywhere. Instead a document-
|
||||
* level `pointerdown` listener — registered in capture phase so it runs
|
||||
* before Base UI's outside-click close handler — commits when the user
|
||||
* actually clicks outside the input.
|
||||
*/
|
||||
function SessionRenameInput({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initialValue: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Hold the latest value + callback in refs so the mount-only effect's
|
||||
// listener always sees fresh state without re-subscribing on every
|
||||
// keystroke (which would briefly leave a window where pointerdown isn't
|
||||
// observed).
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
if (input.contains(e.target as Node)) return;
|
||||
onSubmitRef.current(valueRef.current);
|
||||
};
|
||||
// Capture phase — Base UI registers its own outside-click handler in
|
||||
// bubble; running first lets us commit before the menu starts to
|
||||
// close (and unmount this component).
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
maxLength={200}
|
||||
aria-label={t(($) => $.session_history.row_rename_aria)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
// Stop the menu from stealing arrow / typeahead / space input.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit(value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useFormatTimeAgo(): (dateStr: string) => string {
|
||||
const { t } = useT("chat");
|
||||
return (dateStr: string) => {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"days": "{{count}}d ago"
|
||||
},
|
||||
"row_delete_aria": "Delete chat session",
|
||||
"row_rename_aria": "Rename chat session",
|
||||
"delete_dialog": {
|
||||
"title": "Delete chat session",
|
||||
"description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"days": "{{count}} 天前"
|
||||
},
|
||||
"row_delete_aria": "删除对话",
|
||||
"row_rename_aria": "重命名对话",
|
||||
"delete_dialog": {
|
||||
"title": "删除对话",
|
||||
"description_with_title": "\"{{title}}\" 及其消息会被永久删除,无法撤销。",
|
||||
|
||||
@@ -519,6 +519,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Get("/", h.ListChatSessions)
|
||||
r.Route("/{sessionId}", func(r chi.Router) {
|
||||
r.Get("/", h.GetChatSession)
|
||||
r.Patch("/", h.UpdateChatSession)
|
||||
r.Delete("/", h.DeleteChatSession)
|
||||
r.Post("/messages", h.SendChatMessage)
|
||||
r.Get("/messages", h.ListChatMessages)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -14,6 +15,10 @@ import (
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
// chatSessionTitleMaxLen caps the rename input. Long enough to fit a
|
||||
// meaningful summary, short enough to keep the dropdown row scannable.
|
||||
const chatSessionTitleMaxLen = 200
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,6 +237,66 @@ func (h *Handler) GetChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, chatSessionToResponse(session))
|
||||
}
|
||||
|
||||
type UpdateChatSessionRequest struct {
|
||||
Title *string `json:"title"`
|
||||
}
|
||||
|
||||
// UpdateChatSession updates user-editable fields on a chat session — today
|
||||
// just `title`, surfaced by the inline rename affordance in the session
|
||||
// dropdown. Title is the only field accepted: `status` is legacy + read-only,
|
||||
// agent/creator/workspace are immutable, the resume pointers
|
||||
// (session_id / work_dir / runtime_id) are daemon-owned.
|
||||
func (h *Handler) UpdateChatSession(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := ctxWorkspaceID(r.Context())
|
||||
sessionID := chi.URLParam(r, "sessionId")
|
||||
|
||||
var req UpdateChatSessionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Title == nil {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(*req.Title)
|
||||
if title == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if len([]rune(title)) > chatSessionTitleMaxLen {
|
||||
writeError(w, http.StatusBadRequest, "title is too long")
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := h.gateChatSessionForUser(w, r, userID, workspaceID, sessionID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.Queries.UpdateChatSessionTitle(r.Context(), db.UpdateChatSessionTitleParams{
|
||||
ID: session.ID,
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update chat session")
|
||||
return
|
||||
}
|
||||
|
||||
resolvedSessionID := uuidToString(updated.ID)
|
||||
h.publishChat(protocol.EventChatSessionUpdated, workspaceID, "member", userID, resolvedSessionID, protocol.ChatSessionUpdatedPayload{
|
||||
ChatSessionID: resolvedSessionID,
|
||||
Title: updated.Title,
|
||||
UpdatedAt: timestampToString(updated.UpdatedAt),
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, chatSessionToResponse(updated))
|
||||
}
|
||||
|
||||
// DeleteChatSession hard-deletes a chat session owned by the caller. The
|
||||
// row lock + cancel + delete run inside a single tx so a concurrent
|
||||
// SendChatMessage cannot enqueue a task that would later be orphaned by
|
||||
|
||||
@@ -107,6 +107,62 @@ func TestSendChatMessage_LinksAttachments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateChatSession_RenamesTitle confirms PATCH writes the new title,
|
||||
// returns the updated row, and the server-side row reflects it.
|
||||
func TestUpdateChatSession_RenamesTitle(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "ChatRenameAgent", []byte("[]"))
|
||||
sessionID := createHandlerTestChatSession(t, agentID)
|
||||
|
||||
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
|
||||
"title": " Renamed Session ",
|
||||
})
|
||||
req = withURLParam(req, "sessionId", sessionID)
|
||||
req = withChatTestWorkspaceCtx(t, req)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.UpdateChatSession(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("UpdateChatSession: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp ChatSessionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode update: %v", err)
|
||||
}
|
||||
if resp.Title != "Renamed Session" {
|
||||
t.Fatalf("response title: want %q, got %q", "Renamed Session", resp.Title)
|
||||
}
|
||||
|
||||
var dbTitle string
|
||||
if err := testPool.QueryRow(
|
||||
context.Background(),
|
||||
`SELECT title FROM chat_session WHERE id = $1`,
|
||||
sessionID,
|
||||
).Scan(&dbTitle); err != nil {
|
||||
t.Fatalf("query chat_session: %v", err)
|
||||
}
|
||||
if dbTitle != "Renamed Session" {
|
||||
t.Fatalf("db title: want %q, got %q", "Renamed Session", dbTitle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateChatSession_RejectsBlank refuses an empty/whitespace title with 400.
|
||||
// (Untitled is a render-side fallback, not a stored value.)
|
||||
func TestUpdateChatSession_RejectsBlank(t *testing.T) {
|
||||
agentID := createHandlerTestAgent(t, "ChatRenameBlankAgent", []byte("[]"))
|
||||
sessionID := createHandlerTestChatSession(t, agentID)
|
||||
|
||||
req := newRequest("PATCH", "/api/chat/sessions/"+sessionID, map[string]any{
|
||||
"title": " ",
|
||||
})
|
||||
req = withURLParam(req, "sessionId", sessionID)
|
||||
req = withChatTestWorkspaceCtx(t, req)
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.UpdateChatSession(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("UpdateChatSession blank: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendChatMessage_InvalidAttachmentIDs rejects malformed UUIDs in
|
||||
// attachment_ids with 400 before any side effects (no message row created).
|
||||
func TestSendChatMessage_InvalidAttachmentIDs(t *testing.T) {
|
||||
|
||||
@@ -70,6 +70,7 @@ const (
|
||||
EventChatDone = "chat:done"
|
||||
EventChatSessionRead = "chat:session_read"
|
||||
EventChatSessionDeleted = "chat:session_deleted"
|
||||
EventChatSessionUpdated = "chat:session_updated"
|
||||
|
||||
// Project events
|
||||
EventProjectCreated = "project:created"
|
||||
|
||||
@@ -101,6 +101,16 @@ type ChatSessionDeletedPayload struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
}
|
||||
|
||||
// ChatSessionUpdatedPayload is broadcast when a user-editable field on a
|
||||
// chat session changes (today: title via inline rename). Other tabs/devices
|
||||
// patch the session row in their cached list so the dropdown stays in sync
|
||||
// without a full refetch.
|
||||
type ChatSessionUpdatedPayload struct {
|
||||
ChatSessionID string `json:"chat_session_id"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DaemonHeartbeatRequestPayload is sent from daemon to server over WebSocket
|
||||
// to update last_seen_at and pull pending actions for a single runtime.
|
||||
// Mirrors the body of POST /api/daemon/heartbeat so both transports share
|
||||
|
||||
Reference in New Issue
Block a user