diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 62094a6e428..3f5fb7336e3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -959,7 +959,8 @@ export default function Layout(props: ParentProps) { title: language.t("command.session.archive"), category: language.t("command.category.session"), keybind: "mod+shift+backspace", - disabled: !params.dir || !params.id, + disabled: true, // WIP: archive session feature is work in progress + // TODO: enable this when there's a way to load/restore archive sessions from the UI onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) if (session) archiveSession(session) @@ -1592,6 +1593,904 @@ export default function Layout(props: ParentProps) { setStore("activeWorkspace", undefined) } + const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { + const notification = useNotification() + const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) + const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + + return ( +
+
+ 0 && props.notify }} + /> +
+ 0 && props.notify}> +
+ +
+ ) + } + + const SessionItem = (props: { + session: Session + slug: string + mobile?: boolean + dense?: boolean + popover?: boolean + children?: Map + }): JSX.Element => { + const notification = useNotification() + const notifications = createMemo(() => notification.session.unseen(props.session.id)) + const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const [sessionStore] = globalSync.child(props.session.directory) + const hasPermissions = createMemo(() => { + const permissions = sessionStore.permission?.[props.session.id] ?? [] + if (permissions.length > 0) return true + + const childIDs = props.children?.get(props.session.id) + if (childIDs) { + for (const id of childIDs) { + const childPermissions = sessionStore.permission?.[id] ?? [] + if (childPermissions.length > 0) return true + } + return false + } + + const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) + for (const child of childSessions) { + const childPermissions = sessionStore.permission?.[child.id] ?? [] + if (childPermissions.length > 0) return true + } + return false + }) + const isWorking = createMemo(() => { + if (hasPermissions()) return false + const status = sessionStore.session_status[props.session.id] + return status?.type === "busy" || status?.type === "retry" + }) + + const tint = createMemo(() => { + const messages = sessionStore.message[props.session.id] + if (!messages) return undefined + const user = messages + .slice() + .reverse() + .find((m) => m.role === "user") + if (!user?.agent) return undefined + + const agent = sessionStore.agent.find((a) => a.name === user.agent) + return agentColor(user.agent, agent?.color) + }) + + const hoverMessages = createMemo(() => + sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), + ) + const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) + const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) + const isActive = createMemo(() => props.session.id === params.id) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) + + const hoverPrefetch = { current: undefined as ReturnType | undefined } + const cancelHoverPrefetch = () => { + if (hoverPrefetch.current === undefined) return + clearTimeout(hoverPrefetch.current) + hoverPrefetch.current = undefined + } + const scheduleHoverPrefetch = () => { + if (hoverPrefetch.current !== undefined) return + hoverPrefetch.current = setTimeout(() => { + hoverPrefetch.current = undefined + prefetchSession(props.session) + }, 200) + } + + onCleanup(cancelHoverPrefetch) + + const messageLabel = (message: Message) => { + const parts = sessionStore.part[message.id] ?? [] + const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored) + return text?.text + } + + const item = ( + prefetchSession(props.session, "high")} + onClick={() => { + setState("hoverSession", undefined) + if (layout.sidebar.opened()) return + queueMicrotask(() => setState("hoverProject", undefined)) + }} + > +
+
+ }> + + + + +
+ + +
+ + 0}> +
+ + +
+ 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) => ( +
+ +
+ )} +
+
+
+ ) + + return ( +
+ + {item} + + } + > + setState("hoverSession", open ? props.session.id : undefined)} + > + {language.t("session.messages.loading")}
} + > +
+ { + if (!isActive()) { + sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + navigate(`${props.slug}/session/${props.session.id}`) + return + } + window.history.replaceState(null, "", `#message-${message.id}`) + window.dispatchEvent(new HashChangeEvent("hashchange")) + }} + size="normal" + class="w-60" + /> +
+ + + +
+ setMenu("open", open)}> + + + + + { + if (!menu.pendingRename) return + event.preventDefault() + setMenu("pendingRename", false) + openEditor(`session:${props.session.id}`, props.session.title) + }} + > + { + setMenu("pendingRename", true) + setMenu("open", false) + }} + > + {language.t("common.rename")} + + + {/* WIP: archive session feature is work in progress */} + {/* //TODO: enable this when there's a way to load/restore archive sessions from the UI */} + + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+
+ ) + } + + const NewSessionItem = (props: { slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => { + const label = language.t("command.session.new") + const tooltip = () => props.mobile || !sidebarExpanded() + const item = ( + { + setState("hoverSession", undefined) + if (layout.sidebar.opened()) return + queueMicrotask(() => setState("hoverProject", undefined)) + }} + > +
+
+ +
+ + {label} + +
+
+ ) + + return ( +
+ + {item} + + } + > + {item} + +
+ ) + } + + const SessionSkeleton = (props: { count?: number }): JSX.Element => { + const items = Array.from({ length: props.count ?? 4 }, (_, index) => index) + return ( +
+ + {() =>
} + +
+ ) + } + + 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 = sidebarProject() + if (!project) return + const directory = store.activeWorkspace + if (!directory) return + + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + 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, { bootstrap: false }) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) + const slug = createMemo(() => base64Encode(props.directory)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions(Date.now())), + ) + const children = createMemo(() => { + const map = new Map() + for (const session of workspaceStore.session) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map + }) + const local = createMemo(() => props.directory === props.project.worktree) + const active = createMemo(() => { + const current = decode64(params.dir) ?? "" + return current === props.directory + }) + const workspaceValue = createMemo(() => { + const branch = workspaceStore.vcs?.branch + const name = branch ?? getFilename(props.directory) + return workspaceName(props.directory, props.project.id, branch) ?? name + }) + const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) + const boot = createMemo(() => open() || active()) + const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) + const loading = createMemo(() => open() && !booted() && sessions().length === 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const busy = createMemo(() => isBusy(props.directory)) + const loadMore = async () => { + 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() + } + + createEffect(() => { + if (!boot()) return + globalSync.child(props.directory, { bootstrap: true }) + }) + + const header = () => ( +
+
+ }> + + +
+ + {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} : + + + {workspaceStore.vcs?.branch ?? getFilename(props.directory)} + + } + > + { + const trimmed = next.trim() + if (!trimmed) return + renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch) + setEditor("value", workspaceValue()) + }} + class="text-14-medium text-text-base min-w-0 truncate" + displayClass="text-14-medium text-text-base min-w-0 truncate" + editing={workspaceEditActive()} + stopPropagation={false} + openOnDblClick={false} + /> + + +
+ ) + + return ( +
+ +
+
+
+ + {header()} + + } + > +
{header()}
+
+
+ setMenu("open", open)} + > + + + + + { + if (!menu.pendingRename) return + event.preventDefault() + setMenu("pendingRename", false) + openEditor(`workspace:${props.directory}`, workspaceValue()) + }} + > + { + setMenu("pendingRename", true) + setMenu("open", false) + }} + > + {language.t("common.rename")} + + + dialog.show(() => ( + + )) + } + > + {language.t("common.reset")} + + + dialog.show(() => ( + + )) + } + > + {language.t("common.delete")} + + + + +
+
+
+
+ + + + +
+
+ ) + } + + const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const sortable = createSortable(props.project.worktree) + const selected = createMemo(() => { + const current = decode64(params.dir) ?? "" + return props.project.worktree === current || props.project.sandboxes?.includes(current) + }) + + const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) + const workspaceEnabled = createMemo( + () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), + ) + const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) + + const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) + const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) + const active = createMemo( + () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), + ) + + createEffect(() => { + if (preview()) return + if (!open()) return + setOpen(false) + }) + + const label = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, data.vcs?.branch, props.project.id) + return `${kind} : ${name}` + } + + const sessions = (directory: string) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + const root = workspaceKey(directory) + return data.session + .filter((session) => workspaceKey(session.directory) === root) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions(Date.now())) + .slice(0, 2) + } + + const projectSessions = () => { + const directory = props.project.worktree + const [data] = globalSync.child(directory, { bootstrap: false }) + const root = workspaceKey(directory) + return data.session + .filter((session) => workspaceKey(session.directory) === root) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions(Date.now())) + .slice(0, 2) + } + + const projectName = () => props.project.name || getFilename(props.project.worktree) + const Trigger = () => ( + { + setMenu(value) + if (value) setOpen(false) + }} + > + { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onFocus={() => { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onClick={() => navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + dialog.show(() => )}> + {language.t("common.edit")} + + { + const enabled = layout.sidebar.workspaces(props.project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(props.project.worktree) + return + } + if (props.project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(props.project.worktree) + }} + > + + {layout.sidebar.workspaces(props.project.worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + + ) + + return ( + // @ts-ignore +
+ }> + } + onOpenChange={(value) => { + if (menu()) return + setOpen(value) + if (value) setState("hoverSession", undefined) + }} + > +
+
+
{displayName(props.project)}
+ + { + event.stopPropagation() + setOpen(false) + closeProject(props.project.worktree) + }} + /> + +
+
{language.t("sidebar.project.recentSessions")}
+
+ + {(session) => ( + + )} + + } + > + + {(directory) => ( +
+
+
+ +
+ {label(directory)} +
+ + {(session) => ( + + )} + +
+ )} +
+
+
+
+ +
+
+
+
+
+ ) + } + + const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { + const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree) + const slug = createMemo(() => base64Encode(props.project.worktree)) + const sessions = createMemo(() => + workspaceStore.session + .filter((session) => session.directory === workspaceStore.path.directory) + .filter((session) => !session.parentID && !session.time?.archived) + .toSorted(sortSessions(Date.now())), + ) + const children = createMemo(() => { + const map = new Map() + for (const session of workspaceStore.session) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map + }) + const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) + const loading = createMemo(() => !booted() && sessions().length === 0) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) + const loadMore = async () => { + setWorkspaceStore("limit", (limit) => limit + 5) + await globalSync.project.loadSessions(props.project.worktree) + } + + return ( +
{ + if (!props.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + +
+ ) + } + const createWorkspace = async (project: LocalProject) => { clearSidebarHoverState() const created = await globalSDK.client.worktree