Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 131 additions & 17 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,9 @@ interface SidebarThreadRowProps {
renamingThreadId: ThreadId | null;
renamingTitle: string;
setRenamingTitle: (title: string) => void;
renamingInputRef: MutableRefObject<HTMLInputElement | null>;
renamingCommittedRef: MutableRefObject<boolean>;
onRenamingInputMount: (element: HTMLInputElement | null) => void;
hasRenameCommitted: () => boolean;
markRenameCommitted: () => void;
confirmingArchiveThreadId: ThreadId | null;
setConfirmingArchiveThreadId: Dispatch<SetStateAction<ThreadId | null>>;
confirmArchiveButtonRefs: MutableRefObject<Map<ThreadId, HTMLButtonElement>>;
Expand Down Expand Up @@ -400,30 +401,24 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
{threadStatus && <ThreadStatusLabel status={threadStatus} />}
{props.renamingThreadId === thread.id ? (
<input
ref={(element) => {
if (element && props.renamingInputRef.current !== element) {
props.renamingInputRef.current = element;
element.focus();
element.select();
}
}}
ref={props.onRenamingInputMount}
className="min-w-0 flex-1 truncate text-xs bg-transparent outline-none border border-ring rounded px-0.5"
value={props.renamingTitle}
onChange={(event) => props.setRenamingTitle(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
props.renamingCommittedRef.current = true;
props.markRenameCommitted();
void props.commitRename(thread.id, props.renamingTitle, thread.title);
} else if (event.key === "Escape") {
event.preventDefault();
props.renamingCommittedRef.current = true;
props.markRenameCommitted();
props.cancelRename();
}
}}
onBlur={() => {
if (!props.renamingCommittedRef.current) {
if (!props.hasRenameCommitted()) {
void props.commitRename(thread.id, props.renamingTitle, thread.title);
}
}}
Expand Down Expand Up @@ -718,13 +713,17 @@ export default function Sidebar() {
const [isAddingProject, setIsAddingProject] = useState(false);
const [addProjectError, setAddProjectError] = useState<string | null>(null);
const addProjectInputRef = useRef<HTMLInputElement | null>(null);
const [renamingProjectId, setRenamingProjectId] = useState<ProjectId | null>(null);
const [renamingProjectTitle, setRenamingProjectTitle] = useState("");
const [renamingThreadId, setRenamingThreadId] = useState<ThreadId | null>(null);
const [renamingTitle, setRenamingTitle] = useState("");
const [confirmingArchiveThreadId, setConfirmingArchiveThreadId] = useState<ThreadId | null>(null);
const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState<
ReadonlySet<ProjectId>
>(() => new Set());
const { showThreadJumpHints, updateThreadJumpHintsVisibility } = useThreadJumpHintVisibility();
const projectRenamingCommittedRef = useRef(false);
const projectRenamingInputRef = useRef<HTMLInputElement | null>(null);
const renamingCommittedRef = useRef(false);
const renamingInputRef = useRef<HTMLInputElement | null>(null);
const confirmArchiveButtonRefs = useRef(new Map<ThreadId, HTMLButtonElement>());
Expand Down Expand Up @@ -937,6 +936,28 @@ export default function Sidebar() {
renamingInputRef.current = null;
}, []);

const handleRenamingInputMount = useCallback((element: HTMLInputElement | null) => {
if (element && renamingInputRef.current !== element) {
renamingInputRef.current = element;
element.focus();
element.select();
return;
}
if (element === null && renamingInputRef.current !== null) {
renamingInputRef.current = null;
}
}, []);

const hasRenameCommitted = useCallback(() => renamingCommittedRef.current, []);
const markRenameCommitted = useCallback(() => {
renamingCommittedRef.current = true;
}, []);

const cancelProjectRename = useCallback(() => {
setRenamingProjectId(null);
projectRenamingInputRef.current = null;
}, []);

const commitRename = useCallback(
async (threadId: ThreadId, newTitle: string, originalTitle: string) => {
const finishRename = () => {
Expand Down Expand Up @@ -984,6 +1005,53 @@ export default function Sidebar() {
[],
);

const commitProjectRename = useCallback(
async (projectId: ProjectId, newTitle: string, originalTitle: string) => {
const finishRename = () => {
setRenamingProjectId((current) => {
if (current !== projectId) return current;
projectRenamingInputRef.current = null;
return null;
});
};

const trimmed = newTitle.trim();
if (trimmed.length === 0) {
toastManager.add({
type: "warning",
title: "Project title cannot be empty",
});
finishRename();
return;
}
if (trimmed === originalTitle) {
finishRename();
return;
}
const api = readNativeApi();
if (!api) {
finishRename();
return;
}
try {
await api.orchestration.dispatchCommand({
type: "project.meta.update",
commandId: newCommandId(),
projectId,
title: trimmed,
});
} catch (error) {
toastManager.add({
type: "error",
title: "Failed to rename project",
description: error instanceof Error ? error.message : "An error occurred.",
});
}
finishRename();
},
[],
);

const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{
threadId: ThreadId;
}>({
Expand Down Expand Up @@ -1040,6 +1108,8 @@ export default function Sidebar() {
);

if (clicked === "rename") {
setRenamingProjectId(null);
projectRenamingInputRef.current = null;
setRenamingThreadId(threadId);
setRenamingTitle(thread.title);
renamingCommittedRef.current = false;
Expand Down Expand Up @@ -1206,11 +1276,20 @@ export default function Sidebar() {

const clicked = await api.contextMenu.show(
[
{ id: "rename", label: "Rename project" },
{ id: "copy-path", label: "Copy Project Path" },
{ id: "delete", label: "Remove project", destructive: true },
],
position,
);
if (clicked === "rename") {
setRenamingThreadId(null);
renamingInputRef.current = null;
setRenamingProjectId(projectId);
setRenamingProjectTitle(project.name);
projectRenamingCommittedRef.current = false;
return;
}
if (clicked === "copy-path") {
copyPathToClipboard(project.cwd, { path: project.cwd });
return;
Expand Down Expand Up @@ -1602,9 +1681,43 @@ export default function Sidebar() {
/>
)}
<ProjectFavicon cwd={project.cwd} />
<span className="flex-1 truncate text-xs font-medium text-foreground/90">
{project.name}
</span>
{renamingProjectId === project.id ? (
<input
ref={(element) => {
if (element && projectRenamingInputRef.current !== element) {
projectRenamingInputRef.current = element;
element.focus();
element.select();
}
}}
className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-xs font-medium text-foreground/90 outline-none"
value={renamingProjectTitle}
onChange={(event) => setRenamingProjectTitle(event.target.value)}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
projectRenamingCommittedRef.current = true;
void commitProjectRename(project.id, renamingProjectTitle, project.name);
} else if (event.key === "Escape") {
event.preventDefault();
projectRenamingCommittedRef.current = true;
cancelProjectRename();
}
}}
onBlur={() => {
if (!projectRenamingCommittedRef.current) {
void commitProjectRename(project.id, renamingProjectTitle, project.name);
}
}}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
/>
) : (
<span className="flex-1 truncate text-xs font-medium text-foreground/90">
{project.name}
</span>
)}
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -1693,8 +1806,9 @@ export default function Sidebar() {
renamingThreadId={renamingThreadId}
renamingTitle={renamingTitle}
setRenamingTitle={setRenamingTitle}
renamingInputRef={renamingInputRef}
renamingCommittedRef={renamingCommittedRef}
onRenamingInputMount={handleRenamingInputMount}
hasRenameCommitted={hasRenameCommitted}
markRenameCommitted={markRenameCommitted}
confirmingArchiveThreadId={confirmingArchiveThreadId}
setConfirmingArchiveThreadId={setConfirmingArchiveThreadId}
confirmArchiveButtonRefs={confirmArchiveButtonRefs}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,27 @@ describe("incremental orchestration updates", () => {
expect(next.bootstrapComplete).toBe(false);
});

it("updates the existing project title when project.meta-updated arrives", () => {
const projectId = ProjectId.makeUnsafe("project-1");
const state = makeState(
makeThread({
projectId,
}),
);

const next = applyOrchestrationEvent(
state,
makeEvent("project.meta-updated", {
projectId,
title: "Renamed Project",
updatedAt: "2026-02-27T00:00:01.000Z",
}),
);

expect(next.projects[0]?.name).toBe("Renamed Project");
expect(next.projects[0]?.updatedAt).toBe("2026-02-27T00:00:01.000Z");
});

it("preserves state identity for no-op project and thread deletes", () => {
const thread = makeThread();
const state = makeState(thread);
Expand Down
Loading