diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx index ffa83a61ecf9..56685c66bccf 100644 --- a/packages/app/src/home/sessions/controller.tsx +++ b/packages/app/src/home/sessions/controller.tsx @@ -86,6 +86,7 @@ export function createHomeSessionsController(home: HomeController) { sessions: indexedSessions, projectDirectories, projects: home.project.list, + resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session), }), ) const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) @@ -291,6 +292,7 @@ export function createHomeSessionsController(home: HomeController) { sessions: () => [result], projectDirectories, projects: home.project.list, + resolveProject: ctx.projects.forSession, })[0] }, create: home.project.openNewSession, diff --git a/packages/app/src/home/sessions/records.ts b/packages/app/src/home/sessions/records.ts index d7528477d939..b2a00bcf4f14 100644 --- a/packages/app/src/home/sessions/records.ts +++ b/packages/app/src/home/sessions/records.ts @@ -18,6 +18,7 @@ export function buildHomeSessionRecords(input: { sessions: () => SessionInfo[] projectDirectories: () => string[] | undefined projects: () => LocalProject[] + resolveProject?: (session: SessionInfo) => LocalProject | undefined }) { const selected = input.projectDirectories() const directories = selected ? new Set(selected.map(pathKey)) : undefined @@ -27,11 +28,12 @@ export function buildHomeSessionRecords(input: { return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] .sort(compareSessionTime) .map((session) => { - const project = homeProjectForSession(session, input.projects()) ?? { - id: session.projectID, - worktree: session.location.directory, - expanded: false, - } + const project = input.resolveProject?.(session) ?? + homeProjectForSession(session, input.projects()) ?? { + id: session.projectID, + worktree: session.location.directory, + expanded: false, + } return { session, project, projectName: displayName(project) } }) } diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index e8e83533f7fb..7c685882c8f8 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -18,6 +18,8 @@ import { showToast } from "@/shell/notifications/toast" import { formatServerError } from "./errors" import { useSettings } from "@/settings/model" import { timelinePreset } from "@opencode/session-ui/timeline/detail" +import type { SessionInfo } from "@opencode/client/promise" +import { resolveProjectForSession, resolveSessionDetailsProject } from "@/shell/layout/helpers" export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({ name: "Global", @@ -180,6 +182,13 @@ function createServerController( } const projectsList = createMemo(() => projects.list().map(enrich)) + const forSession = (session: SessionInfo) => { + const project = resolveProjectForSession(session, projectsList(), sync.data.project) + if (!project) return + return "expanded" in project ? project : { ...project, expanded: false } + } + const detailsForSession = (session: SessionInfo) => + resolveSessionDetailsProject(session, projectsList(), sync.data.project) const recentlyClosedList = createMemo(() => { const known = new Set(sync.data.project.map((project) => pathKey(project.worktree))) return projects @@ -200,6 +209,8 @@ function createServerController( projects: { ...projects, list: projectsList, + forSession, + detailsForSession, resolve: enrich, recentlyClosed: recentlyClosedList, }, diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 71fe3a9a2519..a69394ccaac6 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -14,7 +14,6 @@ import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode/ui/resize-handle" import { MessageTimeline } from "@/session/timeline/message-timeline" import { useServer } from "@/runtime/server/current" -import { projectForSession } from "@/shell/layout/helpers" import { ComposerDropzone } from "@/composer/dropzone" import type { SessionModel } from "@/session/model" import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width" @@ -66,7 +65,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp const server = useServer() const detailsProject = createMemo(() => { const info = session.data.info() - return info ? projectForSession(info, server.ctx.sync.data.project) : undefined + return info ? server.ctx.projects.detailsForSession(info) : undefined }) const isDesktop = session.isDesktop const btw = createSessionBtw(session) diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 1100bfc98111..824749508058 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -12,7 +12,7 @@ import { useServer } from "@/runtime/server/current" import { ServerConnection } from "@/runtime/server/registry" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" -import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers" +import { displayName, errorMessage, getProjectAvatarSource } from "@/shell/layout/helpers" import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout" import { tabKey, useTabs } from "@/shell/tabs/tabs" import { useSettingsSurface } from "@/settings/surface" @@ -319,11 +319,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess : sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)), ) const project = createMemo(() => { + if (props.session) return server.ctx.projects.forSession(props.session) const projects = server.ctx.projects.list() - if (props.session) - return ( - projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project) - ) const value = directory() if (!value) return undefined const key = pathKey(value) diff --git a/packages/app/src/session/summary/panel.tsx b/packages/app/src/session/summary/panel.tsx index fc646af893a8..49d92c632b2b 100644 --- a/packages/app/src/session/summary/panel.tsx +++ b/packages/app/src/session/summary/panel.tsx @@ -1,7 +1,7 @@ import { DiffChanges } from "@opencode/ui/diff-changes" import { Icon } from "@opencode/ui/icon" import { getFilename } from "@opencode/util/path" -import { createMemo, Show, type JSX } from "solid-js" +import { createMemo, Show } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import type { Project } from "@/runtime/server/types" import { useSettings } from "@/settings/model" @@ -16,7 +16,6 @@ export function SessionSummaryPanel(props: { shown?: boolean mobile?: boolean project: Project - avatar?: JSX.Element directory: string local: boolean branch?: string @@ -44,7 +43,7 @@ export function SessionSummaryPanel(props: { return (
- + & { id?: string } - avatar?: JSX.Element }>, ) { const settings = useSettings() @@ -26,13 +24,7 @@ export function ProjectSummaryCard( aria-controls={contentID} onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())} > - {props.avatar ?? ( - - )} + {displayName(props.project)} diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 52ffb131e905..321b4c515fe2 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -31,7 +31,6 @@ import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller" import { createTimelineVirtualizer } from "./virtualizer" import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths" -import { projectForSession } from "@/shell/layout/helpers" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" import { useCommand } from "@/shell/commands/command" import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header" @@ -143,14 +142,14 @@ function MessageTimelineView( const session = props.session.data.info() const projects = server.ctx.sync.data.project return session - ? projectForSession(session, projects) + ? server.ctx.projects.detailsForSession(session) : projects.find((item) => containsDirectory(item.worktree, sessionDirectory())) }) const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory())) - const avatarProject = createMemo(() => { + const headerProject = createMemo(() => { const session = props.session.data.info() if (!session) return - return projectForSession(session, server.ctx.projects.list()) ?? project() + return server.ctx.projects.forSession(session) }) createEffect(() => { const directory = project()?.worktree @@ -408,7 +407,7 @@ function MessageTimelineView(
diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index 998b2c1ca02a..9f7000378f42 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -11,7 +11,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout" import { ServerConnection } from "@/runtime/server/registry" import { useServerSDK } from "@/runtime/server/client" import { useTabs } from "@/shell/tabs/tabs" -import { displayName, projectForSession } from "@/shell/layout/helpers" +import { displayName, resolveProjectForSession } from "@/shell/layout/helpers" import { createSessionTabs } from "@/session/helpers" import { useSessionLayout } from "@/session/session-layout" import { useServer } from "@/runtime/server/current" @@ -253,9 +253,7 @@ export function createServerSessionEntries(props: { }) if (current.signal.aborted) return [] const opened = props.opened() - const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : []))) const stored = props.stored().map((project) => ({ ...project, expanded: false })) - const storedByID = new Map(stored.map((project) => [project.id, project] as const)) return Promise.all([ props.load(search, current.signal).then( (result) => result.data, @@ -271,8 +269,7 @@ export function createServerSessionEntries(props: { [...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()] .filter((session) => !session.time.archived) .map((session) => { - const project = - projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID) + const project = resolveProjectForSession(session, opened, stored) return { id: `session:${props.server}:${session.id}`, type: "session" as const, diff --git a/packages/app/src/shell/layout/helpers.ts b/packages/app/src/shell/layout/helpers.ts index 1987b5d46dba..880981624565 100644 --- a/packages/app/src/shell/layout/helpers.ts +++ b/packages/app/src/shell/layout/helpers.ts @@ -50,6 +50,17 @@ export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: export const displayName = (project: { name?: string; worktree: string }) => project.name || getFilename(project.worktree) || project.worktree +type ProjectAppearance = { + name?: string + worktree: string + icon?: { color?: string; url?: string; override?: string } +} + +function withProjectAppearance(metadata: T, appearance?: ProjectAppearance) { + if (!appearance || appearance === metadata) return metadata + return { ...metadata, name: displayName(appearance), icon: appearance.icon } +} + export function toggleHomeProjectSelection( current: HomeProjectSelection | undefined, server: ServerConnection.Key, @@ -97,11 +108,59 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url export function projectForSession( session: SessionInfo, projects: T[], - byID: Map = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) { - const direct = byID.get(session.projectID) - if (direct) return direct - return projects.find((project) => isProjectDirectory(project, session.location.directory)) + const matching = projects.filter((project) => project.id === session.projectID) + if (matching.length === 1) return matching[0] + if (matching.length > 1) { + const directory = pathKey(session.location.directory) + const exact = + matching.find((project) => pathKey(project.worktree) === directory) ?? + matching.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory)) + if (exact) return exact + return ( + matching + .filter((project) => isProjectDirectory(project, session.location.directory)) + .sort((a, b) => b.worktree.length - a.worktree.length)[0] ?? matching.at(-1) + ) + } + const directory = pathKey(session.location.directory) + const exact = + projects.find((project) => pathKey(project.worktree) === directory) ?? + projects.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory)) + if (exact) return exact + return projects + .filter((project) => isProjectDirectory(project, session.location.directory)) + .sort((a, b) => b.worktree.length - a.worktree.length)[0] +} + +export function resolveProjectForSession< + T extends { id?: string; worktree: string; sandboxes?: string[] }, + U extends { id?: string; worktree: string; sandboxes?: string[] }, +>(session: SessionInfo, opened: T[], stored: U[]) { + const current = projectForSession(session, opened) + if (current?.id === session.projectID) { + const unresolved = opened.find( + (project) => !project.id && pathKey(project.worktree) === pathKey(session.location.directory), + ) + if (!unresolved) return current + const canonical = projectForSession(session, stored) + if (canonical?.id === session.projectID && pathKey(canonical.worktree) === pathKey(unresolved.worktree)) + return unresolved + return current + } + const synced = projectForSession(session, stored) + if (synced?.id !== session.projectID) return current ?? synced + if (current && !current.id && pathKey(current.worktree) === pathKey(session.location.directory)) return current + return synced +} + +export function resolveSessionDetailsProject< + T extends ProjectAppearance & { id?: string; sandboxes?: string[] }, + U extends ProjectAppearance & { id?: string; sandboxes?: string[] }, +>(session: SessionInfo, opened: T[], stored: U[]) { + const metadata = projectForSession(session, stored) + if (!metadata) return + return withProjectAppearance(metadata, resolveProjectForSession(session, opened, stored)) } export const errorMessage = (err: unknown, fallback: string) => { diff --git a/packages/app/src/shell/layout/project-icon.tsx b/packages/app/src/shell/layout/project-icon.tsx index 87c028f100aa..da54f6901049 100644 --- a/packages/app/src/shell/layout/project-icon.tsx +++ b/packages/app/src/shell/layout/project-icon.tsx @@ -4,7 +4,7 @@ import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers" import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout" type ProjectIconProps = Omit & { - project: LocalProject + project: Pick fallback?: string icon?: LocalProject["icon"] } diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index b241a9dc5e7f..d9e2cc615c6f 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -9,7 +9,7 @@ import { Menu } from "@opencode/ui/menu" import { useGlobal, useServerCtx } from "@/runtime/server/runtime" import { useLanguage } from "@/runtime/i18n/language" import { ServerConnection, serverName, useServers } from "@/runtime/server/registry" -import { displayName, projectForSession } from "@/shell/layout/helpers" +import { displayName } from "@/shell/layout/helpers" import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" import type { SessionInfo } from "@opencode/client/promise" @@ -57,7 +57,7 @@ export function TabNavItem(props: { const project = createMemo(() => { const session = props.session if (!session) return - return projectForSession(session, serverCtx()?.projects.list() ?? []) + return serverCtx()?.projects.forSession(session) }) const title = createMemo(() => { const session = props.session diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index af2d1eec8229..b90fa873e591 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -29,7 +29,6 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg import { sessionTabTitle } from "./tab-title" import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" -import { projectForSession } from "@/shell/layout/helpers" import { useSettingsDialog } from "@/settings/command" import { updaterAction } from "@/shell/updates/action" import type { UpdaterState } from "@/shell/updates/types" @@ -441,7 +440,7 @@ export function Titlebar(props: { const value = session() if (!tab || !value) return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) - return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : []) + return conn ? global.ensureServerCtx(conn).projects.forSession(value) : undefined }) const currentTitle = () => { const tab = currentTab()