summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-16 13:13:27 -0600
committerAdam <[email protected]>2026-01-16 13:55:59 -0600
commit086603494691fa832239d79bf844880f87f1299c (patch)
treeb9ddb2d20659af633d02b4b1fa09e1f8c207f50a
parent2ccaa10e7961657322beff670b5522f404278c33 (diff)
downloadopencode-086603494691fa832239d79bf844880f87f1299c.tar.gz
opencode-086603494691fa832239d79bf844880f87f1299c.zip
feat(app): edit project and session titles
-rw-r--r--packages/app/src/context/global-sync.tsx51
-rw-r--r--packages/app/src/pages/layout.tsx427
-rw-r--r--packages/ui/src/components/inline-input.css17
-rw-r--r--packages/ui/src/components/inline-input.tsx11
-rw-r--r--packages/ui/src/styles/index.css1
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);