diff options
| author | Adam <[email protected]> | 2026-01-16 13:13:27 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-16 13:55:59 -0600 |
| commit | 086603494691fa832239d79bf844880f87f1299c (patch) | |
| tree | b9ddb2d20659af633d02b4b1fa09e1f8c207f50a | |
| parent | 2ccaa10e7961657322beff670b5522f404278c33 (diff) | |
| download | opencode-086603494691fa832239d79bf844880f87f1299c.tar.gz opencode-086603494691fa832239d79bf844880f87f1299c.zip | |
feat(app): edit project and session titles
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 51 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 427 | ||||
| -rw-r--r-- | packages/ui/src/components/inline-input.css | 17 | ||||
| -rw-r--r-- | packages/ui/src/components/inline-input.tsx | 11 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 1 |
5 files changed, 357 insertions, 150 deletions
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<string, [Store<State>, SetStoreFunction<State>]> = {} + 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<State>({ - 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<State>({ + 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<string, string[]>, + workspaceName: {} as Record<string, string>, workspaceExpanded: {} as Record<string, boolean>, }), ) @@ -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<string> + 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 ( + <Show + when={isEditing()} + fallback={ + <span + class={props.displayClass ?? props.class} + onDblClick={handleDblClick} + onPointerDown={stopPropagation} + onMouseDown={stopPropagation} + onClick={stopPropagation} + onTouchStart={stopPropagation} + > + {props.value()} + </span> + } + > + <InlineInput + ref={(el) => { + 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} + /> + </Show> + ) + } + 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) { </Match> </Switch> </div> - <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"> - {props.session.title} - </span> + <InlineEditor + id={`session:${props.session.id}`} + value={() => 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 + /> <Show when={props.session.summary}> {(summary) => ( <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> @@ -993,116 +1130,6 @@ export default function Layout(props: ParentProps) { ) } - const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { - const sortable = createSortable(props.project.worktree) - const selected = createMemo(() => { - const current = params.dir ? base64Decode(params.dir) : "" - return props.project.worktree === current || props.project.sandboxes?.includes(current) - }) - - const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) - const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) - const label = (directory: string) => { - const [data] = globalSync.child(directory) - const kind = directory === props.project.worktree ? "local" : "sandbox" - const name = data.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name}` - } - - const sessions = (directory: string) => { - const [data] = globalSync.child(directory) - return data.session - .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) - .toSorted(sortSessions) - .slice(0, 2) - } - - const projectSessions = () => { - const [data] = globalSync.child(props.project.worktree) - return data.session - .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) - .toSorted(sortSessions) - .slice(0, 2) - } - - const trigger = ( - <button - type="button" - classList={{ - "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, - "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), - "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected(), - }} - onClick={() => navigateToProject(props.project.worktree)} - > - <ProjectIcon project={props.project} notify /> - </button> - ) - - return ( - // @ts-ignore - <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}> - <div class="-m-3 flex flex-col w-72"> - <div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div> - <div class="px-2 pb-2 flex flex-col gap-2"> - <Show - when={workspaceEnabled()} - fallback={ - <For each={projectSessions()}> - {(session) => ( - <SessionItem - session={session} - slug={base64Encode(props.project.worktree)} - dense - mobile={props.mobile} - /> - )} - </For> - } - > - <For each={workspaces()}> - {(directory) => ( - <div class="flex flex-col gap-1"> - <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> - <div class="shrink-0 size-6 flex items-center justify-center"> - <Icon name="branch" size="small" class="text-icon-base" /> - </div> - <span class="truncate text-14-medium text-text-base">{label(directory)}</span> - </div> - <For each={sessions(directory)}> - {(session) => ( - <SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} /> - )} - </For> - </div> - )} - </For> - </Show> - </div> - <Show when={!selected()}> - <div class="px-2 py-2 border-t border-border-weak-base"> - <Button - variant="ghost" - class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" - onClick={() => { - layout.sidebar.open() - navigateToProject(props.project.worktree) - }} - > - View all sessions - </Button> - </div> - </Show> - </div> - </HoverCard> - </div> - ) - } - const ProjectDragOverlay = (): JSX.Element => { const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject)) return ( @@ -1125,7 +1152,7 @@ export default function Layout(props: ParentProps) { const [workspaceStore] = globalSync.child(directory) const kind = directory === project.worktree ? "local" : "sandbox" - const name = workspaceStore.vcs?.branch ?? getFilename(directory) + const name = workspaceLabel(directory, workspaceStore.vcs?.branch) return `${kind} : ${name}` }) @@ -1149,10 +1176,9 @@ export default function Layout(props: ParentProps) { .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) - const title = createMemo(() => { - const kind = local() ? "local" : "sandbox" + const workspaceValue = createMemo(() => { const name = workspaceStore.vcs?.branch ?? getFilename(props.directory) - return `${kind} : ${name}` + return workspaceName(props.directory) ?? name }) const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true) const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) @@ -1163,23 +1189,50 @@ export default function Layout(props: ParentProps) { 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 <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> - <Collapsible - variant="ghost" - open={open()} - class="shrink-0" - onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)} - > + <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}> <div class="px-2 py-1"> <div class="group/trigger relative"> - <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover transition-all group-hover/trigger:pr-16 group-focus-within/trigger:pr-16"> + <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"> <div class="flex items-center gap-1 min-w-0"> <div class="flex items-center justify-center shrink-0 size-6"> <Icon name="branch" size="small" /> </div> - <span class="truncate text-14-medium text-text-base">{title()}</span> + <span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span> + <Show + when={!local()} + fallback={ + <span class="text-14-medium text-text-base"> + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + </span> + } + > + <InlineEditor + id={`workspace:${props.directory}`} + value={workspaceValue} + onSave={(next) => { + 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} + /> + </Show> <Icon name={open() ? "chevron-down" : "chevron-right"} size="small" @@ -1245,6 +1298,116 @@ export default function Layout(props: ParentProps) { ) } + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => { + const current = params.dir ? base64Decode(params.dir) : "" + return props.project.worktree === current || props.project.sandboxes?.includes(current) + }) + + const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const label = (directory: string) => { + const [data] = globalSync.child(directory) + const kind = directory === props.project.worktree ? "local" : "sandbox" + const name = workspaceLabel(directory, data.vcs?.branch) + return `${kind} : ${name}` + } + + const sessions = (directory: string) => { + const [data] = globalSync.child(directory) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + .slice(0, 2) + } + + const projectSessions = () => { + const [data] = globalSync.child(props.project.worktree) + return data.session + .filter((session) => session.directory === data.path.directory) + .filter((session) => !session.parentID) + .toSorted(sortSessions) + .slice(0, 2) + } + + const trigger = ( + <button + type="button" + classList={{ + "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, + "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), + "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": + !selected(), + }} + onClick={() => navigateToProject(props.project.worktree)} + > + <ProjectIcon project={props.project} notify /> + </button> + ) + + return ( + // @ts-ignore + <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> + <HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}> + <div class="-m-3 flex flex-col w-72"> + <div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div> + <div class="px-2 pb-2 flex flex-col gap-2"> + <Show + when={workspaceEnabled()} + fallback={ + <For each={projectSessions()}> + {(session) => ( + <SessionItem + session={session} + slug={base64Encode(props.project.worktree)} + dense + mobile={props.mobile} + /> + )} + </For> + } + > + <For each={workspaces()}> + {(directory) => ( + <div class="flex flex-col gap-1"> + <div class="px-2 py-0.5 flex items-center gap-1 min-w-0"> + <div class="shrink-0 size-6 flex items-center justify-center"> + <Icon name="branch" size="small" class="text-icon-base" /> + </div> + <span class="truncate text-14-medium text-text-base">{label(directory)}</span> + </div> + <For each={sessions(directory)}> + {(session) => ( + <SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} /> + )} + </For> + </div> + )} + </For> + </Show> + </div> + <Show when={!selected()}> + <div class="px-2 py-2 border-t border-border-weak-base"> + <Button + variant="ghost" + class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" + onClick={() => { + layout.sidebar.open() + navigateToProject(props.project.worktree) + }} + > + View all sessions + </Button> + </div> + </Show> + </div> + </HoverCard> + </div> + ) + } + 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) { <div class="shrink-0 px-2 py-1"> <div class="group/project flex items-start justify-between gap-2 p-2 pr-1"> <div class="flex flex-col min-w-0"> - <span class="text-16-medium text-text-strong truncate">{projectName()}</span> + <InlineEditor + id={`project:${projectId()}`} + value={projectName} + onSave={(next) => project() && renameProject(project()!, next)} + class="text-16-medium text-text-strong truncate" + displayClass="text-16-medium text-text-strong truncate" + stopPropagation + /> + <Tooltip placement="right" value={project()?.worktree} class="shrink-0"> <span class="text-12-regular text-text-base truncate"> {project()?.worktree.replace(homedir(), "~")} </span> </Tooltip> </div> + <DropdownMenu> <DropdownMenu.Trigger as={IconButton} @@ -1469,7 +1642,7 @@ export default function Layout(props: ParentProps) { New workspace </Button> </div> - <div class="flex-1 min-h-0"> + <div class="relative flex-1 min-h-0"> <DragDropProvider onDragStart={handleWorkspaceDragStart} onDragEnd={handleWorkspaceDragEnd} diff --git a/packages/ui/src/components/inline-input.css b/packages/ui/src/components/inline-input.css new file mode 100644 index 000000000..1d8a00e08 --- /dev/null +++ b/packages/ui/src/components/inline-input.css @@ -0,0 +1,17 @@ +[data-component="inline-input"] { + color: inherit; + background: transparent; + border: 0; + border-radius: var(--radius-md); + padding: 0; + min-width: 0; + font: inherit; + letter-spacing: inherit; + line-height: inherit; + box-sizing: border-box; + + &:focus { + outline: none; + box-shadow: 0 0 0 1px var(--border-interactive-focus); + } +} diff --git a/packages/ui/src/components/inline-input.tsx b/packages/ui/src/components/inline-input.tsx new file mode 100644 index 000000000..72711a197 --- /dev/null +++ b/packages/ui/src/components/inline-input.tsx @@ -0,0 +1,11 @@ +import type { ComponentProps } from "solid-js" +import { splitProps } from "solid-js" + +export type InlineInputProps = ComponentProps<"input"> & { + width?: string +} + +export function InlineInput(props: InlineInputProps) { + const [local, others] = splitProps(props, ["class", "width"]) + return <input data-component="inline-input" class={local.class} style={{ width: local.width }} {...others} /> +} 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); |
