From 086603494691fa832239d79bf844880f87f1299c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:13:27 -0600 Subject: [PATCH] feat(app): edit project and session titles --- packages/app/src/context/global-sync.tsx | 51 ++- packages/app/src/pages/layout.tsx | 469 ++++++++++++++------ packages/ui/src/components/inline-input.css | 17 + packages/ui/src/components/inline-input.tsx | 11 + packages/ui/src/styles/index.css | 1 + 5 files changed, 378 insertions(+), 171 deletions(-) create mode 100644 packages/ui/src/components/inline-input.css create mode 100644 packages/ui/src/components/inline-input.tsx diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 82452ed48..2df58eff2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -110,6 +110,7 @@ function createGlobalSync() { }) const children: Record, SetStoreFunction]> = {} + function child(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -122,29 +123,33 @@ function createGlobalSync() { if (!cache) throw new Error("Failed to create persisted cache") vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] }) - children[directory] = createStore({ - project: "", - provider: { all: [], connected: [], default: {} }, - config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - status: "loading" as const, - agent: [], - command: [], - session: [], - sessionTotal: 0, - session_status: {}, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp: {}, - lsp: [], - vcs: cache[0].value, - limit: 5, - message: {}, - part: {}, - }) - bootstrapInstance(directory) + const init = () => { + children[directory] = createStore({ + project: "", + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading" as const, + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: cache[0].value, + limit: 5, + message: {}, + part: {}, + }) + bootstrapInstance(directory) + } + + runWithOwner(owner, init) } const childStore = children[directory] if (!childStore) throw new Error("Failed to create store") diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c48c43b0b..2794b7d02 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -12,6 +12,7 @@ import { Show, Switch, untrack, + type Accessor, type JSX, } from "solid-js" import { A, useNavigate, useParams } from "@solidjs/router" @@ -24,6 +25,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -71,6 +73,7 @@ export default function Layout(props: ParentProps) { activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, workspaceOrder: {} as Record, + workspaceName: {} as Record, workspaceExpanded: {} as Record, }), ) @@ -106,6 +109,104 @@ export default function Layout(props: ParentProps) { dark: "Dark", } + const [editor, setEditor] = createStore({ + active: "" as string, + value: "", + }) + const editorRef = { current: undefined as HTMLInputElement | undefined } + + const editorOpen = (id: string) => editor.active === id + const editorValue = () => editor.value + + const openEditor = (id: string, value: string) => { + if (!id) return + setEditor({ active: id, value }) + queueMicrotask(() => editorRef.current?.focus()) + } + + const closeEditor = () => setEditor({ active: "", value: "" }) + + const saveEditor = (callback: (next: string) => void) => { + const next = editor.value.trim() + if (!next) { + closeEditor() + return + } + closeEditor() + callback(next) + } + + const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => { + if (event.key === "Enter") { + event.preventDefault() + saveEditor(callback) + return + } + if (event.key === "Escape") { + event.preventDefault() + closeEditor() + } + } + + const InlineEditor = (props: { + id: string + value: Accessor + onSave: (next: string) => void + class?: string + displayClass?: string + editing?: boolean + stopPropagation?: boolean + openOnDblClick?: boolean + }) => { + const isEditing = () => props.editing ?? editorOpen(props.id) + const stopEvents = () => props.stopPropagation ?? false + const allowDblClick = () => props.openOnDblClick ?? true + const stopPropagation = (event: Event) => { + if (!stopEvents()) return + event.stopPropagation() + } + const handleDblClick = (event: MouseEvent) => { + if (!allowDblClick()) return + stopPropagation(event) + openEditor(props.id, props.value()) + } + + return ( + + {props.value()} + + } + > + { + editorRef.current = el + }} + value={editorValue()} + class={props.class} + onInput={(event) => setEditor("value", event.currentTarget.value)} + onKeyDown={(event) => editorKeyDown(event, props.onSave)} + onBlur={() => closeEditor()} + onPointerDown={stopPropagation} + onClick={stopPropagation} + onDblClick={stopPropagation} + onMouseDown={stopPropagation} + onMouseUp={stopPropagation} + onTouchStart={stopPropagation} + /> + + ) + } + function cycleTheme(direction = 1) { const ids = availableThemeEntries().map(([id]) => id) if (ids.length === 0) return @@ -299,6 +400,12 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) }) + const workspaceName = (directory: string) => store.workspaceName[directory] + const workspaceLabel = (directory: string, branch?: string) => + workspaceName(directory) ?? branch ?? getFilename(directory) + + const isWorkspaceEditing = () => editor.active.startsWith("workspace:") + const workspaceSetting = createMemo(() => { const project = currentProject() if (!project) return false @@ -700,6 +807,31 @@ export default function Layout(props: ParentProps) { if (navigate) navigateToProject(directory) } + const displayName = (project: LocalProject) => project.name || getFilename(project.worktree) + + async function renameProject(project: LocalProject, next: string) { + if (!project.id) return + const current = displayName(project) + if (next === current) return + const name = next === getFilename(project.worktree) ? "" : next + await globalSDK.client.project.update({ projectID: project.id, name }) + } + + async function renameSession(session: Session, next: string) { + if (next === session.title) return + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + title: next, + }) + } + + const renameWorkspace = (directory: string, next: string) => { + const current = workspaceName(directory) ?? getFilename(directory) + if (current === next) return + setStore("workspaceName", directory, next) + } + function closeProject(directory: string) { const index = layout.projects.list().findIndex((x) => x.worktree === directory) const next = layout.projects.list()[index + 1] @@ -953,9 +1085,14 @@ export default function Layout(props: ParentProps) { - - {props.session.title} - + props.session.title} + onSave={(next) => renameSession(props.session, next)} + class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" + stopPropagation + /> {(summary) => (
@@ -993,6 +1130,174 @@ export default function Layout(props: ParentProps) { ) } + const ProjectDragOverlay = (): JSX.Element => { + const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) + return ( + + {(p) => ( +
+ +
+ )} +
+ ) + } + + const WorkspaceDragOverlay = (): JSX.Element => { + const label = createMemo(() => { + const project = currentProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory) + const kind = directory === project.worktree ? "local" : "sandbox" + const name = workspaceLabel(directory, workspaceStore.vcs?.branch) + return `${kind} : ${name}` + }) + + return ( + + {(value) => ( +
{value()}
+ )} +
+ ) + } + + const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.directory) + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions), + ) + const local = createMemo(() => props.directory === props.project.worktree) + const workspaceValue = createMemo(() => { + const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) + return workspaceName(props.directory) ?? name + }) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) + const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) + const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) + const loadMore = async () => { + if (!local()) return + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.directory) + } + + const workspaceEditActive = createMemo(() => editorOpen(`workspace:${props.directory}`)) + + const openWrapper = (value: boolean) => { + setStore("workspaceExpanded", props.directory, value) + if (value) return + if (editorOpen(`workspace:${props.directory}`)) closeEditor() + } + + return ( + // @ts-ignore +
+ +
+
+ +
+
+ +
+ {local() ? "local" : "sandbox"} : + + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + + } + > + { + const trimmed = next.trim() + if (!trimmed) return + renameWorkspace(props.directory, trimmed) + setEditor("value", workspaceValue()) + }} + class="text-14-medium text-text-base" + displayClass="text-14-medium text-text-base" + editing={workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + + +
+
+ +
+
+ + + +
+
+ ) + } + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { @@ -1005,7 +1310,7 @@ export default function Layout(props: ParentProps) { const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? "local" : "sandbox" - const name = data.vcs?.branch ?? getFilename(directory) + const name = workspaceLabel(directory, data.vcs?.branch) return `${kind} : ${name}` } @@ -1103,148 +1408,6 @@ export default function Layout(props: ParentProps) { ) } - const ProjectDragOverlay = (): JSX.Element => { - const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) - return ( - - {(p) => ( -
- -
- )} -
- ) - } - - const WorkspaceDragOverlay = (): JSX.Element => { - const label = createMemo(() => { - const project = currentProject() - if (!project) return - const directory = store.activeWorkspace - if (!directory) return - - const [workspaceStore] = globalSync.child(directory) - const kind = directory === project.worktree ? "local" : "sandbox" - const name = workspaceStore.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name}` - }) - - return ( - - {(value) => ( -
{value()}
- )} -
- ) - } - - const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.directory) - const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory) - const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) - .toSorted(sortSessions), - ) - const local = createMemo(() => props.directory === props.project.worktree) - const title = createMemo(() => { - const kind = local() ? "local" : "sandbox" - const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) - return `${kind} : ${name}` - }) - const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) - const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) - const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) - const loadMore = async () => { - if (!local()) return - setWorkspaceStore("limit", (limit) => limit + 5) - await globalSync.project.loadSessions(props.directory) - } - - return ( - // @ts-ignore -
- setStore("workspaceExpanded", props.directory, value)} - > -
-
- -
-
- -
- {title()} - -
-
- -
-
- - - -
-
- ) - } - const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) const slug = createMemo(() => base64Encode(props.project.worktree)) @@ -1306,6 +1469,7 @@ export default function Layout(props: ParentProps) { if (!current) return "" return current.name || getFilename(current.worktree) }) + const projectId = createMemo(() => project()?.id ?? "") const workspaces = createMemo(() => workspaceIds(project())) const errorMessage = (err: unknown) => { @@ -1406,13 +1570,22 @@ export default function Layout(props: ParentProps) {
- {projectName()} + project() && renameProject(project()!, next)} + class="text-16-medium text-text-strong truncate" + displayClass="text-16-medium text-text-strong truncate" + stopPropagation + /> + {project()?.worktree.replace(homedir(), "~")}
+
-
+
& { + width?: string +} + +export function InlineInput(props: InlineInputProps) { + const [local, others] = splitProps(props, ["class", "width"]) + return +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 6e8bbe385..8ab4d6ca4 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -25,6 +25,7 @@ @import "../components/icon-button.css" layer(components); @import "../components/image-preview.css" layer(components); @import "../components/text-field.css" layer(components); +@import "../components/inline-input.css" layer(components); @import "../components/list.css" layer(components); @import "../components/logo.css" layer(components); @import "../components/markdown.css" layer(components);