Skip to main content

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 — add customTitle column to the Conversation entity.
  • Modify: api/src/conversations/conversations.dto.ts — add RenameConversationDto.
  • Modify: api/src/conversations/conversations.service.ts — add renameConversation().
  • Modify: api/src/conversations/conversations.controller.ts — add PATCH :id/rename.
  • Modify: api/src/conversations/conversations.service.spec.ts — service tests.

Frontend (frontend/):

  • Modify: frontend/src/lib/api.ts — add customTitle to Conversation interface + renameConversation() helper.
  • Modify: frontend/src/components/client/PortalChat.tsx — wire handleRenameThread to the API (optimistic + rollback); add customTitle to all 4 title-resolution sites.
  • Modify: frontend/src/components/client/__tests__/ThreadListItem.test.tsx — assert rename commit calls onRename (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 the subject column 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 after UpdateStatusDto, 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 after dismissByClient, 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's beforeEach/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 after dismissConversation, around line 340; import RenameConversationDto)

  • 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 near resolveConversation ~line 1078)

  • Step 1: Add customTitle to the Conversation interface

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 renameConversation helper

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 apiFetch body/header convention first: confirm other PATCH-with-body callers in this file pass body: JSON.stringify(...) and that apiFetch sets Content-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 handleRenameThread to 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.");
}
}, []);

conversationsRef already 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 customTitle to all four title-resolution sites

Make these exact edits in PortalChat.tsx:

  1. Line 332 (filter):
      const name = (c.customTitle || threadNames[c.id] || c.subject || "New thread").toLowerCase();
  1. 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";
  1. threadSubject() helper (lines 1117-1121):
  const raw =
c.customTitle ||
names?.[c.id] ||
c.subject ||
c.messages?.[0]?.content?.slice(0, 80) ||
"New thread";
  1. Lines 2737-2740 (MessageThread header — currently has no access to threadNames, 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 has aria-label="Rename thread: <name>" (ThreadListItem.tsx:570) and the input has aria-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_title is 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 + findOne IDOR. Endpoint intentionally ungated by PlatformRolesGuard (matches /resolve, /dismiss).
  • Legacy localStorage renames: preserved as a read-only fallback (threadNames) below customTitle; 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: customTitle used identically in entity (customTitle: string | null), DTO (customTitle: string), FE type (customTitle?: string | null), helper arg, and all resolution sites. Endpoint path :id/rename matches the FE helper URL.