Skip to content

fix(claude-sync): propagate session renames between Claude CLI and UI - #1068

Open
thevinchi wants to merge 1 commit into
siteboon:mainfrom
thevinchi:fix/claude-rename-propagation
Open

fix(claude-sync): propagate session renames between Claude CLI and UI#1068
thevinchi wants to merge 1 commit into
siteboon:mainfrom
thevinchi:fix/claude-rename-propagation

Conversation

@thevinchi

@thevinchi thevinchi commented Jul 29, 2026

Copy link
Copy Markdown

What

Session renames never take effect: /rename in the Claude CLI doesn't reach the sidebar, and a name is frozen after the first sync. Two guards in processSessionFile cause it:

  1. It early-returns the stored custom_name whenever it isn't the literal 'Untitled Claude Session' — so once named, a session can never be renamed.
  2. It checks the history.jsonl map (first-entry-wins) before scanning the transcript, so every session with a history entry is named after its first prompt, and the existing custom-title branch is unreachable in practice.

The custom-title parsing is already written — it just never runs.

How

  • Resolution order: custom-titleai-title → stored name → history first prompt → last-prompt, scanning from the end so the newest event of each type wins (matching Claude Code's own last-wins policy).
  • Write-back: a UI rename appends the same custom-title event Claude Code writes on /rename and reads back for its session picker, so renames propagate both directions and converge on the most recent. Best-effort — never creates a missing file, and a failure is logged rather than failing the already-persisted rename.

Keeping the stored name in the chain (below both transcript titles) means a write-back that failed isn't reverted to the first prompt on next sync. It's non-regressive for existing names, since those are the history first prompt.

Relationship to #982

@swu45's #982 covers the same title-priority reorder and I'd rather not duplicate that work. The difference:

Happy either way: land #982 first and I'll rebase this down to the guard removal plus write-back, or take this as the superset and close #982 with credit. Both add server/modules/providers/tests/claude-sessions.test.ts, so they'll conflict on that path whichever lands first.

Testing

New claude-sessions.test.ts covers custom-title beating the history prompt, a later custom-title replacing a stored name, the fallback order, subagent transcripts still skipped, the rename append, and rename surviving a missing transcript.

npx tsx --tsconfig server/tsconfig.json --test server/modules/providers/tests/claude-sessions.test.ts
# tests 7  # pass 7  # fail 0

Lint, typecheck, and npm run build pass.

Summary by CodeRabbit

  • New Features

    • Claude session names now prioritize custom titles, AI-generated titles, history names, and prompts for more accurate naming.
    • Renaming a Claude session is synchronized with its transcript when available.
    • Session renames remain successful even if the transcript file is unavailable.
  • Bug Fixes

    • Fixed session rename responses to return completed results.
    • Prevented subagent transcripts from overwriting parent session details.

Session names were frozen after first sync: the synchronizer returned the
stored custom_name whenever one existed, and the history.jsonl lookup (first
prompt text, first-entry-wins) shadowed the transcript title scan, so the
custom-title events Claude Code writes on /rename never took effect.

- Resolve titles as custom-title > ai-title > stored name > history first
  prompt > last-prompt, scanning the transcript from the end so the newest
  event of each type wins (matching Claude's own last-wins merge policy).
- Mirror UI renames into the transcript as a custom-title event (best
  effort, never creates the file) so Claude Code's own session picker shows
  the new name and both sides converge on the latest rename.
- Add synchronizer + rename write-back test coverage.
@thevinchi
thevinchi marked this pull request as ready for review July 29, 2026 16:41
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Claude session synchronization now resolves titles from transcript events, history, and stored names. Session renames await persistence and append best-effort custom-title events to Claude transcripts, with coverage for fallback ordering, subagents, missing files, and provider session IDs.

Changes

Claude title synchronization

Layer / File(s) Summary
Transcript title resolution
server/modules/providers/list/claude/claude-session-synchronizer.provider.ts, server/modules/providers/tests/claude-sessions.test.ts
Transcript scanning collects title events and applies the custom-title, AI-title, stored-name, history, and last-prompt precedence chain. Tests cover fallback behavior, newest titles, subagents, and isolated environments.
Rename propagation
server/modules/providers/services/sessions.service.ts, server/modules/providers/provider.routes.ts, server/modules/providers/tests/claude-sessions.test.ts
Renames are awaited, persisted, and appended to existing Claude transcripts as provider-session custom-title events. Tests cover synchronization convergence, missing transcripts, and app-created sessions.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ProviderRoutes
  participant SessionsService
  participant SessionsDatabase
  participant ClaudeTranscript
  Client->>ProviderRoutes: PUT session rename
  ProviderRoutes->>SessionsService: await renameSessionById
  SessionsService->>SessionsDatabase: update custom_name
  SessionsService->>ClaudeTranscript: append custom-title event
  SessionsService-->>ProviderRoutes: return sessionId and summary
  ProviderRoutes-->>Client: return resolved response
Loading

Possibly related PRs

Suggested reviewers: blackmammoth

Poem

I’m a rabbit with titles tucked neat in a line,
Custom names hop forward, while old ones decline.
A rename leaves footprints in transcripts of white,
Awaited replies now return just right.
thump thump—Claude’s sessions grow bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: syncing session renames between Claude CLI and the UI.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@server/modules/providers/list/claude/claude-session-synchronizer.provider.ts`:
- Around line 146-160: Update the session-name resolution around
extractSessionTitlesFromEnd and existingSessionName to preserve provenance for
names created by explicit custom-title renames. When an existing session is
marked as explicitly renamed, ignore titles.aiTitle and retain
existingSessionName if no newer titles.customTitle is available; keep aiTitle
fallback for sessions without that explicit-rename marker, followed by the
existing nameMap and lastPrompt fallbacks.

In `@server/modules/providers/services/sessions.service.ts`:
- Around line 58-62: Update the transcript append flow to remove the fsp.access
pre-check and open session.jsonl_path directly with a non-creating mode such as
r+. Write the event through the returned FileHandle, close it reliably, and
treat ENOENT/EACCES as non-fatal best-effort rename outcomes while preserving
the existing handling for other errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1587a475-bb7c-46f2-81e4-4074e4e2199b

📥 Commits

Reviewing files that changed from the base of the PR and between 75ff8a5 and 9ce941a.

📒 Files selected for processing (4)
  • server/modules/providers/list/claude/claude-session-synchronizer.provider.ts
  • server/modules/providers/provider.routes.ts
  • server/modules/providers/services/sessions.service.ts
  • server/modules/providers/tests/claude-sessions.test.ts

Comment on lines +146 to +160
const existingSessionName = existingSession?.custom_name !== 'Untitled Claude Session'
? existingSession?.custom_name ?? undefined
: undefined;

// The transcript is the source of truth for explicit names: `/rename` in the
// Claude CLI and a rename in this UI both land as a `custom-title` event, and
// the reverse scan below returns the newest one. The stored name only ranks
// above the history.jsonl first prompt so a UI rename whose transcript
// write-back failed is not silently reverted to that prompt.
const titles = await this.extractSessionTitlesFromEnd(filePath, parsed.sessionId);
const sessionName = titles.customTitle
?? titles.aiTitle
?? existingSessionName
?? nameMap.get(parsed.sessionId)
?? titles.lastPrompt;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files matching claude session synchronizer =="
fd -a 'claude-session-synchronizer.provider.ts' . || true

file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
if [ -n "${file:-}" ]; then
  echo "== file: $file =="
  wc -l "$file"
  echo "== outline =="
  ast-grep outline "$file" --view expanded || true
  echo "== relevant lines 120-180 =="
  sed -n '120,180p' "$file" | nl -ba -v120
  echo "== related title extraction definitions/usages =="
  rg -n "extractSessionTitlesFromEnd|aiTitle|customTitle|lastPrompt|appendClaudeCustomTitle|custom_name|Untitled Claude Session" .
fi

echo "== changed files summary =="
git diff --stat || true

Repository: siteboon/claudecodeui

Length of output: 1203


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
echo "== full relevant file ==" >&2
sed -n '1,260p' "$file" | nl -ba -v1

echo "== sessions.service relevant section =="
svc="$(fd 'sessions.service.ts' . | head -n 1)"
if [ -n "${svc:-}" ]; then
  wc -l "$svc"
  rg -n "appendClaudeCustomTitle|custom_name|create.*session|set.*session" "$svc"
  rg -n -C 6 "appendClaudeCustomTitle|custom_name|create.*session|set.*session" "$svc"
fi

Repository: siteboon/claudecodeui

Length of output: 224


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'claude-session-synchronizer.provider.ts' . | head -n 1)"
echo "== file: $file =="

echo "== lines 1-80 =="
sed -n '1,80p' "$file"

echo "== lines 81-190 =="
sed -n '81,190p' "$file"

echo "== lines 191-235 =="
sed -n '191,235p' "$file"

svc="$(fd 'sessions.service.ts' . | head -n 1)"
if [ -n "${svc:-}" ]; then
  echo "== sessions.service.ts: $svc =="
  rg -n -C 8 "appendClaudeCustomTitle|custom_name|create.*custom|set.*custom|update.*custom" "$svc"
fi

echo "== tests mentioning title/event/write-back =="
rg -n -C 4 "ai-title|custom-title|Untitled Claude Session|custom_name|appendClaude|write-back|rename" server -S || true

Repository: siteboon/claudecodeui

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== claude-sessions.test.ts around rename tests =="
file="$(fd 'claude-sessions.test.ts' . | head -n 1)"
sed -n '1,250p' "$file"

echo "== deterministic title lookup simulation from source =="
python3 - <<'PY'
titles_cases = [
    {"aiTitle": "Generated title", "existingSessionName": "Renamed in the UI", "nameMap": "History prompt", "lastPrompt": "Ignored last prompt"},
    {"customTitle": "New rename", "aiTitle": "Generated title", "existingSessionName": "Renamed in the UI", "lastPrompt": "Ignored current"},
    {"lastPrompt": "History prompt", "existingSessionName": "Renamed in the UI", "lastPrompt2": "Ignored last prompt"},
]
for i, titles in enumerate(titles_cases, 1):
    existingSessionName = titles["existingSessionName"] if titles.get("existingSessionName") not in ("", None, "Untitled Claude Session") else None
    sessionName = titles.get("customTitle") or titles.get("aiTitle") or existingSessionName or titles.get("nameMap") or titles.get("lastPrompt")
    print(f"case {i}: {sessionName!r}")
PY

Repository: siteboon/claudecodeui

Length of output: 10554


Protect failed rename write-backs from stale ai-title events.

appendClaudeCustomTitle() writes the renamed value and is documented as best-effort so the persisted rename is not reverted, but the synchronizer precedence is customTitle ?? aiTitle ?? existingSessionName. If a previous sync already seeded ai-title and the append fails, the next sync overwrites Renamed in the UI with that stale generated title. Track which persisted names came from explicit custom-title renames so generated ai-title events are ignored for already-renamed sessions, otherwise explicit renames can be silently reverted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/providers/list/claude/claude-session-synchronizer.provider.ts`
around lines 146 - 160, Update the session-name resolution around
extractSessionTitlesFromEnd and existingSessionName to preserve provenance for
names created by explicit custom-title renames. When an existing session is
marked as explicitly renamed, ignore titles.aiTitle and retain
existingSessionName if no newer titles.customTitle is available; keep aiTitle
fallback for sessions without that explicit-rename marker, followed by the
existing nameMap and lastPrompt fallbacks.

Comment on lines +58 to +62
try {
// Never create the transcript: appendFile would happily materialize a file
// that Claude never wrote, leaving an orphan in ~/.claude/projects.
await fsp.access(session.jsonl_path);
await fsp.appendFile(session.jsonl_path, `${event}\n`, 'utf8');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)sessions\.service\.ts$|package\.json$|tsconfig.*\.json$' || true

echo "== relevant code =="
if [ -f server/modules/providers/services/sessions.service.ts ]; then
  sed -n '1,110p' server/modules/providers/services/sessions.service.ts | cat -n
fi

echo "== package/tooling excerpt =="
if [ -f package.json ]; then
  node -e 'const p=require("./package.json"); console.log(JSON.stringify({dependencies:p.dependencies, devDependencies:p.devDependencies}, null, 2))'
fi

echo "== node fs appendFile behavior probe (readonly temp files) =="
node - <<'JS'
const os = require('os');
const fs = require('fs');
const path = require('path');

async function probe() {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'toc-tou-probe-'));
  const missing = path.join(dir, 'missing.jsonl');
  const present = path.join(dir, 'present.jsonl');
  fs.writeFileSync(present, 'old\n');
  fs.chmodSync(present, 0o444); // read-only
  let thrown = false;
  let created = false;
  try {
    await fs.promises.access(present);
    await fs.promises.appendFile(present, 'new\n', 'utf8');
  } catch (err) {
    thrown = true;
    console.log('read-only error:', err.code, err.message.split('\n')[0]);
  }
  await fs.promises.access(missing);
  try {
    await fs.promises.appendFile(missing, 'new\n', 'utf8');
    created = true;
    console.log('missing file missing.stat.mode before delete:', fs.statSync(missing).mode.toString(8).slice(-4));
  } catch (err) {
    thrown = true;
    console.log('missing error:', err.code, err.message.split('\n')[0]);
  }
  if (created) fs.unlinkSync(missing);
  console.log('appendFile created missing:', created, 'thrown on existing read-only:', thrown);
  fs.rmSync(dir, { recursive: true, force: true });
}
probe().catch(err => { console.error('probe failed:', err); process.exit(1); });
JS

Repository: siteboon/claudecodeui

Length of output: 8386


🌐 Web query:

Node.js fsPromises.access() open read/write race condition access before open not recommended

💡 Result:

Using fsPromises.access to check for the accessibility of a file before performing an operation like open, readFile, or writeFile is not recommended [1][2]. This practice introduces a Time-of-Check to Time-of-Use (TOCTOU) race condition [3]. The core issue is that the file system state can change between the moment access confirms accessibility and the moment the subsequent operation executes [1][3][4]. For example, another process could delete, rename, or change the permissions of the file in the interim, rendering the initial check obsolete [1][3]. The recommended approach is to perform the file operation directly (e.g., calling fsPromises.open or fsPromises.readFile immediately) and handle any resulting errors [1][2][4]. This ensures that the check and the use of the file are part of a single, atomic operation relative to the operating system's handling of the file descriptor [3]. You should generally only use fsPromises.access when you do not intend to perform an immediate operation on the file, such as for [1][5]: - Performing one-time startup diagnostics to verify environment readiness. - Choosing among candidate paths. - Checking for the presence of an optional file to influence user experience (where a missing file is not an error). - Detecting signals from other processes where the file's existence or accessibility itself is the intended data point [1][5].

Citations:


Don’t pre-check transcript availability with access() before appending.

access() followed by appendFile() is racy: the transcript can disappear between the two calls, and appendFile()’s default append mode can create a new file — defeating the “never create the transcript” invariant. Open the file directly with a non-create flag (for example r+) and write via the returned FileHandle, handling ENOENT/EACCES as a non-fatal best-effort rename outcome.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/modules/providers/services/sessions.service.ts` around lines 58 - 62,
Update the transcript append flow to remove the fsp.access pre-check and open
session.jsonl_path directly with a non-creating mode such as r+. Write the event
through the returned FileHandle, close it reliably, and treat ENOENT/EACCES as
non-fatal best-effort rename outcomes while preserving the existing handling for
other errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant