feat(app): edit project and session titles

This commit is contained in:
Adam
2026-01-16 13:13:27 -06:00
parent 2ccaa10e79
commit 0866034946
5 changed files with 378 additions and 171 deletions

View File

@@ -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")

View File

@@ -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,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 (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-xl p-1">
<ProjectIcon project={p()} />
</div>
)}
</Show>
)
}
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 (
<Show when={label()}>
{(value) => (
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
)}
</Show>
)
}
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
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<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-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="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"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<TooltipKeybind
class="pointer-events-auto"
placement="right"
title="New session"
keybind={command.keybind("session.new")}
>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
</div>
</div>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 px-2">
<Button
as={A}
href={`${slug()}/session`}
variant="ghost"
size="large"
icon="edit"
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
>
New session
</Button>
<Show when={loading()}>
<SessionSkeleton />
</Show>
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
Load more
</Button>
</div>
</Show>
</nav>
</Collapsible.Content>
</Collapsible>
</div>
)
}
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 (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-xl p-1">
<ProjectIcon project={p()} />
</div>
)}
</Show>
)
}
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 (
<Show when={label()}>
{(value) => (
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
)}
</Show>
)
}
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
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible
variant="ghost"
open={open()}
class="shrink-0"
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
>
<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">
<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>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<TooltipKeybind
class="pointer-events-auto"
placement="right"
title="New session"
keybind={command.keybind("session.new")}
>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
</div>
</div>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 px-2">
<Button
as={A}
href={`${slug()}/session`}
variant="ghost"
size="large"
icon="edit"
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
>
New session
</Button>
<Show when={loading()}>
<SessionSkeleton />
</Show>
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
Load more
</Button>
</div>
</Show>
</nav>
</Collapsible.Content>
</Collapsible>
</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}

View File

@@ -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);
}
}

View File

@@ -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} />
}

View File

@@ -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);