fix(app): align workspace interactions and styling with designs

This commit is contained in:
LukeParkerDev 2026-08-06 11:26:02 +10:00
parent c40aecbdbe
commit a597364516
7 changed files with 154 additions and 113 deletions

View file

@ -104,7 +104,9 @@ test("selects local, new, and existing workspaces from the ready-ish start menu"
await trigger.click()
const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ })
await expect(newWorkspace).toBeVisible()
await expect(page.getByRole("menuitem", { name: /Workspace/ })).toBeVisible()
const workspaceTrigger = page.getByRole("menuitem", { name: /Workspace/ })
await expect(workspaceTrigger).toBeVisible()
await workspaceTrigger.hover()
await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible()
await newWorkspace.click()
@ -133,7 +135,7 @@ test("searches long workspace lists within the available viewport", async ({ pag
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("button", { name: /local|new workspace/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).focus()
await page.keyboard.press("ArrowRight")
@ -203,7 +205,9 @@ test("lists and manually deletes workspaces from settings", async ({ page }) =>
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
@ -215,7 +219,8 @@ test("lists and manually deletes workspaces from settings", async ({ page }) =>
const firstSessionInventory = sessionListRequests
await page.keyboard.press("Escape")
await expect(settings).toHaveCount(0)
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("button", { name: /local|new workspace/i }).click()
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
expect(sessionListRequests).toBeGreaterThan(firstSessionInventory)
@ -273,6 +278,8 @@ test("blocks deletion of the currently active workspace", async ({ page }) => {
await page.goto(`/new-session?draftId=${draftID}`)
await page.getByRole("button", { name: /feature/ }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
@ -313,7 +320,9 @@ test("wraps the workspace toolbar for long project filters on mobile", async ({
await page.goto(`/new-session?draftId=${draftID}`)
const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" })
if (await dismissTabs.isVisible()) await dismissTabs.click()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible()
@ -389,7 +398,9 @@ test("bulk deletion snapshots inventory and skips dirty or unknown workspaces",
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await settings.getByRole("button", { name: "More options" }).click()

View file

@ -129,7 +129,17 @@ export function PromptWorkspaceSelector(props: {
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
@ -199,14 +209,14 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Item>
)}
</For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
@ -225,12 +235,12 @@ export function PromptWorkspaceSelector(props: {
</div>
</Show>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} connected={selected() === "create"} />
<PromptGitStatus branch={props.branch} from={selected() === "create"} />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; connected?: boolean }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
@ -239,32 +249,30 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
return props.branch
}
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return (
<Show when={label()}>
{(value) => (
<>
<Show when={!props.connected}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div
class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]"
classList={{ "ml-0.5": props.connected }}
>
<Icon
name={props.noGit ? "monitor" : "branch"}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon
name={icon()}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
)}
</Show>
)

View file

@ -7,6 +7,7 @@ import { For, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettingsDialog } from "@/components/settings-dialog"
import { pathKey } from "@/utils/path-key"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
@ -35,6 +36,7 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
const blocked = () =>
@ -126,28 +128,31 @@ export function SessionWorkspaceMenu(props: {
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="w-[200px]">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Group>
<Show when={workspaces().length > 0}>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="w-[200px]">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>

View file

@ -784,8 +784,11 @@
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
margin-top: 4px;
}
.settings-v2-workspaces-session {
@ -794,26 +797,29 @@
align-items: center;
justify-content: space-between;
gap: 12px;
overflow: hidden;
padding: 8px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-active {
.settings-v2-workspaces-session-time {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
line-height: 14px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {

View file

@ -107,6 +107,10 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const sessionTime = (session: Session) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [status, sessions] = await Promise.all([
@ -331,22 +335,29 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
</span>
</TooltipV2>
<Show when={lastActive(workspace)}>
{(value) => <span class="settings-v2-workspaces-active">{value()}</span>}
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session, index) => (
<span class="settings-v2-workspaces-session">
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={index() === 0}>
<span class="settings-v2-workspaces-session-active">
{language.t("settings.workspaces.lastActiveSession")}
</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</span>
</div>
)}
</For>
</div>

View file

@ -269,7 +269,7 @@ function WorkspaceMoveAction(props: {
classList={{
"group/workspace-move relative shrink-0": true,
"ml-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03":
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
invisible: props.dismissed,
}}
@ -286,10 +286,10 @@ function WorkspaceMoveAction(props: {
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 pt-[10px] text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<IconV2 name={inline() ? "workspace-new" : "workspace"} class="shrink-0 text-v2-icon-icon-muted" />
<IconV2 name={inline() ? "workspace-new" : "workspace-new"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
@ -343,31 +343,21 @@ function SessionSummaryPanel(props: {
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate">{displayName(props.project)}</span>
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
</div>
<Show
when={props.local}
fallback={
<div class={row}>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate">{location()}</span>
</div>
}
<SessionWorkspaceMenu
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement="left-start"
gutter={-22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<SessionWorkspaceMenu
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement="left-start"
gutter={-22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<IconV2 name="monitor" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
</Show>
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
@ -1453,27 +1443,33 @@ export function MessageTimeline(props: {
return (
<TimelineRowFrame row={workspaceRow}>
<div class={`w-full ${turnPadding()}`} aria-live="polite">
<div class="flex h-11 w-full items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
<Show when={!pending()} fallback={<TextShimmer text={status()} />}>
<span
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
<Show
when={!pending()}
fallback={
<div class="flex items-center gap-1.5">
<TextShimmer text={status()} />
</div>
}
>
<div
classList={{
"flex min-w-0 items-center gap-1.5": true,
"flex items-center gap-1.5": true,
"text-v2-state-fg-danger": operation().status === "failed",
}}
>
<Show when={operation().status !== "failed"}>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
</Show>
<Show when={directory()}>
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
</Show>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-faint lowercase"}>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>
{status()}
</span>
</span>
<Show when={operation().status !== "failed"}>
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={directory()}>
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
</Show>
</Show>
</div>
</Show>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
</div>
</div>
</TimelineRowFrame>

View file

@ -18,6 +18,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<path d="M5.118 5.686V10.314M5.118 5.686C5.97 5.686 6.661 4.995 6.661 4.143C6.661 3.291 5.97 2.6 5.118 2.6C4.266 2.6 3.575 3.291 3.575 4.143C3.575 4.995 4.266 5.686 5.118 5.686ZM5.118 10.314C4.266 10.314 3.575 11.005 3.575 11.857C3.575 12.709 4.266 13.4 5.118 13.4C5.97 13.4 6.661 12.709 6.661 11.857M5.118 10.314C5.97 10.314 6.661 11.005 6.661 11.857M10.882 5.686C11.734 5.686 12.425 4.995 12.425 4.143C12.425 3.291 11.734 2.6 10.882 2.6C10.03 2.6 9.339 3.291 9.339 4.143C9.339 4.995 10.03 5.686 10.882 5.686ZM10.882 5.686V9.457C10.882 10.783 9.807 11.857 8.482 11.857H6.661" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
},
"branch-out": {
viewBox: "0 0 16 16",
body: `<path d="M10.4225 3.35355L12.9024 5.83344L10.4225 8.31333" stroke="currentColor"/><path d="M1 12.2852H4.23042C4.89912 12.2852 5.52359 11.951 5.89452 11.3946L9.00783 6.72462C8.37877 6.16823 10.0032 5.83402 10.6719 5.83402H12.9024" stroke="currentColor"/><path d="M8.5 12.2852H14" stroke="currentColor" stroke-linejoin="round"/>`,
},
"grid-plus": {
viewBox: "0 0 16 16",
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,