Thread Rename Persistence (#1874) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make client thread renames persist server-side so they survive reloads, cross-device sessions, and are visible on every surface (sidebar, header, Expert/AM views).
Architecture: Add a nullable custom_title column to conversations that the client can set via a new client-accessible PATCH /conversations/:id/rename endpoint (IDOR enforced by the service findOne, same auth model as /resolve and /dismiss). custom_title is user-owned and always wins over the LLM-generated subject (which stays regenerable). The frontend stops storing renames only in localStorage and instead writes to the conversation via the API with an optimistic update + rollback; title resolution everywhere becomes customTitle ?? threadNames(legacy) ?? subject ?? "New thread".
Tech Stack: NestJS 11 + TypeORM (Postgres 16), Next.js 16 + React 19, Jest (api: better-sqlite3 locally / Postgres in CI; frontend: jsdom).
Why this design (root cause): handleRenameThread (PortalChat.tsx:3320) only writes React state + localStorage["hw_threadNames"] and makes no API call. There is no rename endpoint and no lib/api.ts helper. conversation.subject is deliberately reserved for the regenerable LLM title (PortalChat.tsx:3206-3211), so renames must NOT reuse it — hence a dedicated custom_title column.
Branch: fix/1874-thread-rename-persist (off dev).
Note on the frontend toolchain: frontend/AGENTS.md warns this is a non-standard Next.js build — read node_modules/next/dist/docs/ before writing any frontend code if a Next API is involved. (This change touches only client components + a fetch helper, no Next-specific APIs.)
Multi-tenancy: custom_title lives on the conversations row, which is already Org × Specialist scoped. No new isolation boundary — the column inherits conversation scoping and the rename endpoint reuses findOne's IDOR enforcement.
File Structure
Backend (api/):
- Create:
api/migrations/1796000000001-AddCustomTitleToConversations.ts— additive nullable column (expand step). - Modify:
api/src/common/entities.ts:208-210— addcustomTitlecolumn to theConversationentity. - Modify:
api/src/conversations/conversations.dto.ts— addRenameConversationDto. - Modify:
api/src/conversations/conversations.service.ts— addrenameConversation(). - Modify:
api/src/conversations/conversations.controller.ts— addPATCH :id/rename. - Modify:
api/src/conversations/conversations.service.spec.ts— service tests.
Frontend (frontend/):
- Modify:
frontend/src/lib/api.ts— addcustomTitletoConversationinterface +renameConversation()helper. - Modify:
frontend/src/components/client/PortalChat.tsx— wirehandleRenameThreadto the API (optimistic + rollback); addcustomTitleto all 4 title-resolution sites. - Modify:
frontend/src/components/client/__tests__/ThreadListItem.test.tsx— assert rename commit callsonRename(regression guard for the widget).
Task 1: Migration — add custom_title column (expand)
Files:
-
Create:
api/migrations/1796000000001-AddCustomTitleToConversations.ts -
Step 1: Write the migration
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* AddCustomTitleToConversations — adds nullable `custom_title` to `conversations`
* so a client-set thread name persists server-side (#1874).
*
* Design:
* - custom_title = user-chosen thread name; ALWAYS wins over `subject`.
* - subject = auto/LLM-generated title; stays regenerable and is never
* clobbered by a rename (see PortalChat.tsx:3206-3211).
* - NULL custom_title → fall back to subject → "New thread".
*
* Expand-contract: additive nullable column, no backfill, no dual-write needed.
* Old pods ignore the column; new pods read/write it. Safe in active-active.
*
* Rollback: DROP COLUMN is safe — no FKs reference custom_title.
*/
export class AddCustomTitleToConversations1796000000001
implements MigrationInterface
{
name = "AddCustomTitleToConversations1796000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "conversations"
ADD COLUMN IF NOT EXISTS "custom_title" character varying
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "conversations"
DROP COLUMN IF EXISTS "custom_title"
`);
}
}
- Step 2: Verify the migration compiles and is discovered
Run: cd api && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -i custom_title || echo "no type errors in migration"
Expected: no type errors in migration
- Step 3: Commit
git add api/migrations/1796000000001-AddCustomTitleToConversations.ts
git commit -m "feat(1874): add custom_title column to conversations (expand)"
Task 2: Entity — add customTitle to Conversation
Files:
-
Modify:
api/src/common/entities.ts(immediately after thesubjectcolumn at line 208-210) -
Step 1: Add the column
Insert directly after the existing subject column block (api/src/common/entities.ts:208-210):
/**
* User-set thread title (#1874). Client-editable via PATCH
* /conversations/:id/rename. Always takes precedence over the auto-generated
* `subject` in every title-resolution site; NULL means "use subject".
* Isolation: inherits the conversation row's Org × Specialist scope.
*/
@Column({ name: "custom_title", nullable: true })
customTitle: string | null;
- Step 2: Verify it compiles
Run: cd api && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: no errors referencing entities.ts / customTitle.
- Step 3: Commit
git add api/src/common/entities.ts
git commit -m "feat(1874): add customTitle field to Conversation entity"
Task 3: DTO — RenameConversationDto
Files:
-
Modify:
api/src/conversations/conversations.dto.ts(append afterUpdateStatusDto, around line 145) -
Step 1: Add the DTO
Append to api/src/conversations/conversations.dto.ts (the file already imports IsString, IsNotEmpty; add MaxLength to the existing class-validator import if not present):
export class RenameConversationDto {
@IsString()
@IsNotEmpty()
@MaxLength(60)
customTitle: string;
}
- Step 2: Verify the import + compile
Run: cd api && grep -nE "MaxLength" src/conversations/conversations.dto.ts && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: MaxLength appears in a class-validator import; no compile errors.
- Step 3: Commit
git add api/src/conversations/conversations.dto.ts
git commit -m "feat(1874): add RenameConversationDto"
Task 4: Service — renameConversation() (TDD)
Files:
-
Modify:
api/src/conversations/conversations.service.ts(add method afterdismissByClient, around line 2705) -
Test:
api/src/conversations/conversations.service.spec.ts(add to the existing"ConversationsService sibling coverage"describe block) -
Step 1: Write the failing test
Add inside the describe("ConversationsService sibling coverage", …) block in conversations.service.spec.ts. This block already constructs a service with mocked repos; mirror the existing updateStatus/dismiss tests' style. The mocked convRepo exposes findOne and update jest mocks (reuse the block's existing setup helpers/vars — match their names).
describe("renameConversation", () => {
it("persists custom_title and returns the updated conversation", async () => {
const caller = { userId: "u1", orgId: "org1", platformRole: "org_member" };
// findOne is called twice: once for IDOR, once for the post-update return.
convRepo.findOne
.mockResolvedValueOnce({ id: "c1", orgId: "org1", status: "pending" })
.mockResolvedValueOnce({ id: "c1", orgId: "org1", customTitle: "Renamed" });
const result = await service.renameConversation(
"c1",
{ customTitle: "Renamed" },
caller,
);
expect(convRepo.update).toHaveBeenCalledWith("c1", { customTitle: "Renamed" });
expect(result).toEqual(
expect.objectContaining({ id: "c1", customTitle: "Renamed" }),
);
});
it("trims surrounding whitespace before persisting", async () => {
const caller = { userId: "u1", orgId: "org1", platformRole: "org_member" };
convRepo.findOne
.mockResolvedValueOnce({ id: "c1", orgId: "org1", status: "pending" })
.mockResolvedValueOnce({ id: "c1", orgId: "org1", customTitle: "Renamed" });
await service.renameConversation("c1", { customTitle: " Renamed " }, caller);
expect(convRepo.update).toHaveBeenCalledWith("c1", { customTitle: "Renamed" });
});
it("enforces IDOR — a cross-org caller cannot rename", async () => {
const caller = { userId: "u2", orgId: "other-org", platformRole: "org_member" };
// findOne throws ForbiddenException for a mismatched org (existing IDOR path).
convRepo.findOne.mockResolvedValueOnce({ id: "c1", orgId: "org1", status: "pending" });
await expect(
service.renameConversation("c1", { customTitle: "Hacked" }, caller),
).rejects.toThrow();
expect(convRepo.update).not.toHaveBeenCalled();
});
});
Note: if the sibling-coverage block names its repo mock differently (e.g.
conversationRepo), use that name. Read the block'sbeforeEach/provider setup first and match it exactly — do not invent a new mock.
- Step 2: Run the test to verify it fails
Run: cd api && npx jest conversations.service.spec --runInBand -t "renameConversation" 2>&1 | tail -20
Expected: FAIL — service.renameConversation is not a function.
- Step 3: Implement
renameConversation
Add to conversations.service.ts immediately after dismissByClient (after its closing } around line 2705):
// ── PATCH /conversations/:id/rename ──────────────────────────── ──────────
/**
* Client-initiated rename: sets a user-owned `customTitle` that always wins
* over the auto-generated `subject`. Available to any authenticated user;
* IDOR is enforced by findOne() (Org × Conversation). #1874.
*/
async renameConversation(
id: string,
dto: RenameConversationDto,
caller?: { userId?: string; orgId?: string; platformRole?: string },
): Promise<Conversation> {
const conv = await this.findOne(id, caller);
const customTitle = dto.customTitle.trim();
await this.convRepo.update(id, { customTitle });
await this.auditService.log({
userId: caller?.userId,
orgId: caller?.orgId,
action: "conversation.renamed",
resourceType: "conversation",
resourceId: id,
payload: { previousTitle: conv.customTitle ?? null, newTitle: customTitle },
});
return this.convRepo.findOne({ where: { id } }) as Promise<Conversation>;
}
Add RenameConversationDto to the existing import from ./conversations.dto at the top of the service file.
- Step 4: Run the test to verify it passes
Run: cd api && npx jest conversations.service.spec --runInBand -t "renameConversation" 2>&1 | tail -20
Expected: PASS (3 tests).
- Step 5: Commit
git add api/src/conversations/conversations.service.ts api/src/conversations/conversations.service.spec.ts
git commit -m "feat(1874): add renameConversation service method with IDOR + trim"
Task 5: Controller — PATCH /conversations/:id/rename
Files:
-
Modify:
api/src/conversations/conversations.controller.ts(add afterdismissConversation, around line 340; importRenameConversationDto) -
Step 1: Add the endpoint
Add after the dismissConversation handler. Mirror the /dismiss auth model — no PlatformRolesGuard (clients rename their own threads; IDOR enforced by the service):
/**
* PATCH /conversations/:id/rename
* Set a user-owned thread title. Available to any authenticated user;
* IDOR is enforced at the service layer by findOne(). #1874.
*/
@Patch(":id/rename")
@HttpCode(HttpStatus.OK)
renameConversation(
@Param("id") id: string,
@Body() dto: RenameConversationDto,
@Request() req: any,
) {
return this.conversationsService.renameConversation(id, dto, {
userId: req.user?.userId,
orgId: req.user?.orgMemberships?.[0]?.orgId ?? req.user?.orgId,
platformRole: req.user?.platformRole,
});
}
Add RenameConversationDto to the existing DTO import block at the top of the controller.
- Step 2: Verify the API builds
Run: cd api && npm run build 2>&1 | tail -15
Expected: build succeeds (no TS errors).
- Step 3: Run the conversations suite to confirm nothing broke
Run: cd api && npx jest conversations.service.spec conversations.controller --runInBand 2>&1 | tail -20
Expected: PASS (controller spec, if present, plus service spec).
- Step 4: Commit
git add api/src/conversations/conversations.controller.ts
git commit -m "feat(1874): add PATCH /conversations/:id/rename endpoint"
Task 6: Frontend API helper + type
Files:
-
Modify:
frontend/src/lib/api.ts(Conversation interface at line 753-777; add helper nearresolveConversation~line 1078) -
Step 1: Add
customTitleto theConversationinterface
In frontend/src/lib/api.ts, add directly above subject?: string | null; (line 771):
/** User-set thread title (#1874). Wins over `subject` in all title
* resolution. NULL/undefined → fall back to subject → "New thread". */
customTitle?: string | null;
- Step 2: Add the
renameConversationhelper
Add next to resolveConversation (around line 1078), matching its style:
/** Rename a thread — sets a user-owned title that persists server-side and
* wins over the auto-generated subject. Usable by clients (#1874). */
export async function renameConversation(
id: string,
customTitle: string,
): Promise<Conversation> {
return apiFetch<Conversation>(`/conversations/${id}/rename`, {
method: "PATCH",
body: JSON.stringify({ customTitle }),
});
}
Verify the
apiFetchbody/header convention first: confirm other PATCH-with-body callers in this file passbody: JSON.stringify(...)and thatapiFetchsetsContent-Type: application/json. If they instead pass a plain object, match that convention instead.
- Step 3: Verify it compiles + lints
Run: cd frontend && npx tsc --noEmit 2>&1 | tail -5 && npm run lint 2>&1 | tail -5
Expected: no type errors; lint clean.
- Step 4: Commit
git add frontend/src/lib/api.ts
git commit -m "feat(1874): add renameConversation API helper + customTitle type"
Task 7: Wire PortalChat to the API + fix all title-resolution sites
Files:
-
Modify:
frontend/src/components/client/PortalChat.tsx -
Step 1: Import the helper
Add renameConversation to the existing import from @/lib/api (the same import that already brings in archiveConversation, dismissConversation, etc.). toast is already imported (used at line 3371).
- Step 2: Rewrite
handleRenameThreadto persist via API (optimistic + rollback)
Replace the body of handleRenameThread (PortalChat.tsx:3320-3330) with:
const handleRenameThread = useCallback(async (id: string, name: string) => {
const prev = conversationsRef.current.find((c) => c.id === id);
const prevTitle = prev?.customTitle ?? null;
// Optimistic: set customTitle on the conversation so every title-resolution
// site (sidebar, header, filter) reflects it immediately.
setConversations((cs) =>
cs.map((c) => (c.id === id ? { ...c, customTitle: name } : c)),
);
try {
await renameConversation(id, name);
} catch {
// Roll back to the prior title and tell the user.
setConversations((cs) =>
cs.map((c) => (c.id === id ? { ...c, customTitle: prevTitle } : c)),
);
toast.error("Failed to rename thread. Please try again.");
}
}, []);
conversationsRefalready exists in this component (used at line 3213). If lint flags it as a missing dep, that's expected for a ref — refs are stable and don't belong in the dep array.
- Step 3: Prepend
customTitleto all four title-resolution sites
Make these exact edits in PortalChat.tsx:
- Line 332 (filter):
const name = (c.customTitle || threadNames[c.id] || c.subject || "New thread").toLowerCase();
- Lines 650-651 (sidebar render) — also update the comment at 647:
// Title precedence: customTitle (server) → legacy local rename → subject → "New thread".
const rawLabel =
conv.customTitle || threadNames[conv.id] || conv.subject || "New thread";
threadSubject()helper (lines 1117-1121):
const raw =
c.customTitle ||
names?.[c.id] ||
c.subject ||
c.messages?.[0]?.content?.slice(0, 80) ||
"New thread";
- Lines 2737-2740 (
MessageThreadheader — currently has no access tothreadNames, so customTitle is what finally makes user renames show in the header):
const subject =
conversation.customTitle ||
conversation.subject ||
conversation.messages?.[0]?.content?.slice(0, 80) ||
"New thread";
- Step 4: Verify compile + lint + existing frontend tests
Run: cd frontend && npx tsc --noEmit 2>&1 | tail -5 && npm run lint 2>&1 | tail -5 && npx jest PortalChat ThreadListItem 2>&1 | tail -20
Expected: no type/lint errors; existing PortalChat/ThreadListItem tests pass.
- Step 5: Commit
git add frontend/src/components/client/PortalChat.tsx
git commit -m "fix(1874): persist thread rename via API + customTitle precedence in all title sites"
Task 8: Frontend regression test for the rename widget
Files:
-
Modify:
frontend/src/components/client/__tests__/ThreadListItem.test.tsx -
Step 1: Write the test
Add a test that confirms committing a rename calls onRename with the trimmed value (guards the widget wiring that surfaces the bug). Match the file's existing render-helper/import style (it already passes onRename={jest.fn()}); use @testing-library/react + userEvent as the file does.
it("calls onRename with the trimmed new name on Enter", async () => {
const onRename = jest.fn();
const user = userEvent.setup();
render(
<ThreadListItem
conversation={{ id: "c1", status: "pending", messages: [] } as any}
threadName="Old name"
isActive={false}
onSelect={jest.fn()}
onRename={onRename}
/>,
);
await user.click(screen.getByRole("button", { name: /rename thread/i }));
const input = await screen.findByRole("textbox", { name: /rename thread/i });
await user.clear(input);
await user.type(input, " New name {Enter}");
expect(onRename).toHaveBeenCalledWith("c1", "New name");
});
Adjust the conversation fixture shape and the rename-trigger query to match this test file's existing helpers (read its other
it(...)blocks first). The rename trigger hasaria-label="Rename thread: <name>"(ThreadListItem.tsx:570) and the input hasaria-label="Rename thread"(line 441).
- Step 2: Run the test
Run: cd frontend && npx jest ThreadListItem 2>&1 | tail -20
Expected: PASS (existing tests + the new one).
- Step 3: Commit
git add frontend/src/components/client/__tests__/ThreadListItem.test.tsx
git commit -m "test(1874): regression guard for thread rename commit"
Task 9: Full verification
- Step 1: API tests (sqlite local)
Run: cd api && npm test 2>&1 | tail -25
Expected: full suite green. (Postgres-specific paths run in CI; the additive column needs no Postgres-only behaviour.)
- Step 2: API migration applies forward (best-effort local)
If a local Postgres is available, run: cd api && npm run migrations:ci 2>&1 | tail -25
Expected: migration applies cleanly. (Otherwise this is enforced by the CI api-postgres-migrations job.)
- Step 3: Frontend build + tests
Run: cd frontend && npm run build 2>&1 | tail -15 && npx jest 2>&1 | tail -20
Expected: build succeeds; tests pass.
- Step 4: Manual sanity (optional but recommended)
Use the verify skill / run the app: rename a thread, reload the page, confirm the name persists; open the same thread in a second browser/incognito and confirm the rename is visible (proves server persistence, not just localStorage).
- Step 5: Open the PR
git push -u origin fix/1874-thread-rename-persist
gh pr create --base dev --title "fix(1874): persist thread renames server-side" \
--body "Closes #1874. Adds conversations.custom_title + PATCH /conversations/:id/rename; frontend now persists renames via the API (optimistic + rollback) instead of localStorage-only. customTitle wins over the LLM subject in all title-resolution sites."
Self-Review Notes
- Spec coverage: Persist rename (Tasks 1-6) ✓; survive reload/cross-device (server column + all resolution sites read it, Task 7) ✓; reflect immediately (optimistic update, Task 7 Step 2) ✓; fail-loud instead of silent (rollback + toast, Task 7 Step 2) ✓; header now shows renames (Task 7 Step 3.4) ✓.
- Expand-contract: Only the additive expand step is needed —
custom_titleis new and nullable, no backfill, no drop/rename. Compliant with the single-PR migration rules. - Multi-tenancy: No new isolation boundary; reuses conversation Org × Specialist scope +
findOneIDOR. Endpoint intentionally ungated byPlatformRolesGuard(matches/resolve,/dismiss). - Legacy localStorage renames: preserved as a read-only fallback (
threadNames) belowcustomTitle; new renames go to the server. No migration of old localStorage values (YAGNI — they resolve until the server title is set, then server wins). - Type consistency:
customTitleused identically in entity (customTitle: string | null), DTO (customTitle: string), FE type (customTitle?: string | null), helper arg, and all resolution sites. Endpoint path:id/renamematches the FE helper URL.