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()