arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
+ .toBeGreaterThan(0)
+ await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
+})
diff --git a/packages/app/e2e/screenshots/session-project-menu.png b/packages/app/e2e/screenshots/session-project-menu.png
new file mode 100644
index 00000000000..854417a99b7
Binary files /dev/null and b/packages/app/e2e/screenshots/session-project-menu.png differ
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 78fdbc21562..ad53263635f 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -954,6 +954,7 @@ export const dict = {
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"dialog.server.authenticate.title": "Authenticate",
+ "project.settings.title": "Edit project",
"project.settings.general.description": "Manage project name and appearance",
"project.settings.scripts": "Scripts",
"project.settings.scripts.description": "Configure scripts for this project",
diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css
new file mode 100644
index 00000000000..1c7d6b47b4e
--- /dev/null
+++ b/packages/app/src/session/session-identity-header.css
@@ -0,0 +1,55 @@
+/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
+[data-popper-positioner]:has(.session-project-info-tooltip),
+.session-project-info-tooltip {
+ pointer-events: none !important;
+}
+
+.session-options-menu [data-component="menu-v2-item"] {
+ padding-inline-end: 30px;
+}
+
+.session-project-link {
+ --session-project-fade-direction: to right;
+ position: relative;
+
+ &:dir(rtl) {
+ --session-project-fade-direction: to left;
+ }
+
+ &[aria-disabled="true"] {
+ opacity: 0.5;
+ }
+}
+
+.session-project-link-content {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ gap: 8px;
+}
+
+.session-project-link-open {
+ position: absolute;
+ inset-inline-end: 12px;
+ inset-block: 0;
+ display: flex;
+ align-items: center;
+ color: var(--v2-icon-icon-muted);
+ pointer-events: none;
+ opacity: 0;
+}
+
+.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
+ .session-project-link-content {
+ mask-image: linear-gradient(
+ var(--session-project-fade-direction),
+ #000 calc(100% - 40px),
+ transparent calc(100% - 24px)
+ );
+ }
+
+ .session-project-link-open {
+ opacity: 1;
+ }
+}
diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx
index f0c1528f3f7..2506609a088 100644
--- a/packages/app/src/session/session-identity-header.tsx
+++ b/packages/app/src/session/session-identity-header.tsx
@@ -1,18 +1,27 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Menu } from "@opencode-ai/ui/menu"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
+import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
-import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
-import { getProjectAvatarVariant } from "@/shell/state/layout"
+import { usePlatform } from "@/runtime/platform/platform"
+import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
+import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
-import { isWorkspaceDirectory } from "@/workspaces/paths"
+import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
+import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
+import "./session-identity-header.css"
export function SessionTitleHeader(props: ParentProps) {
return (
@@ -25,6 +34,173 @@ export function SessionTitleHeader(props: ParentProps) {
)
}
+export function SessionProjectMenu(props: {
+ project?: Omit
+ directory?: string
+ workspace: boolean
+ showProjectIcon: boolean
+}) {
+ const server = useServer()
+ const language = useLanguage()
+ const dialog = useDialog()
+ const platform = usePlatform()
+ const layout = useLayout()
+ const navigate = useNavigate()
+ const [state, setState] = createStore({
+ open: false,
+ projectTruncated: false,
+ pathTruncated: false,
+ pathFocused: false,
+ })
+ const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
+ const canOpenPath = () =>
+ platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
+ const openPath = () => {
+ if (!canOpenPath() || !platform.openPath || !props.directory) return
+ void platform.openPath(props.directory).catch((cause: unknown) =>
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: errorMessage(cause, language.t("common.requestFailed")),
+ }),
+ )
+ }
+ const openProjectSettings = async () => {
+ const current = props.project
+ if (!current) return
+ const { DialogEditProject } = await import("@/settings/workspaces/project-dialog")
+ dialog.push(() => )
+ }
+
+ return (
+
+ )
+}
+
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
@@ -71,12 +247,17 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
const project = createMemo(() => {
const projects = server.ctx.projects.list()
- if (props.session) return projectForSession(props.session, projects)
+ 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)
- return projects.find(
- (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
+ return (
+ projects.find(
+ (item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
+ ) ?? server.ctx.sync.data.project.find((item) => isProjectDirectory(item, value))
)
})
const showProjectIcon = () =>
@@ -95,25 +276,13 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
-
-
- }
- >
-
-
-
+
+
{(value) => (