Skip to content

Commit 7537fe4

Browse files
committed
feat: add no-workspace welcome screen flow
1 parent 346452b commit 7537fe4

14 files changed

Lines changed: 842 additions & 179 deletions

File tree

apps/web/src/components/RuntimeValidationOverlay/RuntimeValidationOverlay.tsx

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { useEffect } from "react";
12
import type { Translator } from "../../i18n";
23
import type { ExecTarget } from "../../state/workbench";
4+
import { HeaderCloseIcon } from "../icons";
35

46
export type RuntimeRequirementId = "claude" | "git";
57

@@ -25,6 +27,7 @@ type RuntimeValidationOverlayProps = {
2527
runtimeLabel: string;
2628
validation: RuntimeValidationState;
2729
onUpdateTarget: (target: ExecTarget) => void;
30+
onClose: () => void;
2831
onRetry: () => void;
2932
t: Translator;
3033
};
@@ -50,9 +53,25 @@ export const RuntimeValidationOverlay = ({
5053
runtimeLabel,
5154
validation,
5255
onUpdateTarget,
56+
onClose,
5357
onRetry,
5458
t,
5559
}: RuntimeValidationOverlayProps) => {
60+
useEffect(() => {
61+
if (!visible) return undefined;
62+
63+
const onKeyDown = (event: KeyboardEvent) => {
64+
if (event.key === "Escape") {
65+
onClose();
66+
}
67+
};
68+
69+
window.addEventListener("keydown", onKeyDown);
70+
return () => {
71+
window.removeEventListener("keydown", onKeyDown);
72+
};
73+
}, [visible, onClose]);
74+
5675
if (!visible) return null;
5776

5877
const summaryDescription = validation.status === "failed"
@@ -61,12 +80,24 @@ export const RuntimeValidationOverlay = ({
6180
const retryDisabled = validation.status !== "failed";
6281

6382
return (
64-
<div className="overlay" data-testid="runtime-validation-overlay" data-density="compact">
65-
<div className="modal onboarding-modal">
83+
<div className="overlay" data-testid="runtime-validation-overlay" data-density="compact" onClick={onClose}>
84+
<div className="modal onboarding-modal" onClick={(event) => event.stopPropagation()}>
6685
<div className="onboarding-form runtime-check-shell">
67-
<div className="onboarding-header">
68-
<h2>{t("runtimeCheckTitle")}</h2>
69-
<p>{t("runtimeCheckDescription")}</p>
86+
<div className="onboarding-header runtime-check-header">
87+
<div className="runtime-check-header-copy">
88+
<h2>{t("runtimeCheckTitle")}</h2>
89+
<p>{t("runtimeCheckDescription")}</p>
90+
</div>
91+
<button
92+
type="button"
93+
className="runtime-check-close"
94+
onClick={onClose}
95+
aria-label={t("close")}
96+
title={t("close")}
97+
data-testid="runtime-validation-close"
98+
>
99+
<HeaderCloseIcon />
100+
</button>
70101
</div>
71102

72103
{canUseWsl && (

apps/web/src/components/TopBar/TopBar.tsx

Lines changed: 102 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -37,97 +37,108 @@ export const TopBar = ({
3737
onCloseSettings,
3838
onOpenCommandPalette,
3939
t
40-
}: TopBarProps) => (
41-
<header className={`topbar ${isSettingsRoute ? "topbar-settings" : ""}`}>
42-
<div className="topbar-tabs-wrap">
43-
{isSettingsRoute ? (
44-
<div className="route-topbar" data-testid="settings-topbar">
45-
<button className="route-topbar-back" type="button" onClick={onCloseSettings}>
46-
<HeaderBackIcon />
47-
<span>{t("backToApp")}</span>
48-
</button>
49-
<div className="route-topbar-title">{t("settings")}</div>
50-
</div>
51-
) : (
52-
<div className="topbar-session-strip topbar-workspace-strip" data-testid="workspace-topbar">
53-
<button
54-
type="button"
55-
className={`session-top-history ${historyOpen ? "active" : ""}`}
56-
onClick={onToggleHistory}
57-
title={t("history")}
58-
aria-label={t("history")}
59-
data-testid="history-toggle"
60-
>
61-
<HeaderHistoryIcon />
62-
</button>
63-
{workspaceTabs.map((item) => (
64-
<div
65-
key={item.id}
66-
role="button"
67-
tabIndex={0}
68-
className={`session-top-tab workspace-top-tab ${item.active ? "active" : ""} ${item.hasRunning ? "running-glow" : ""}`}
69-
onClick={() => onSwitchWorkspace(item.id)}
70-
onKeyDown={(event) => {
71-
if (event.key === "Enter" || event.key === " ") {
72-
event.preventDefault();
73-
onSwitchWorkspace(item.id);
74-
}
75-
}}
76-
title={item.label}
40+
}: TopBarProps) => {
41+
const hasWorkspaceTabs = workspaceTabs.length > 0;
42+
43+
return (
44+
<header className={`topbar ${isSettingsRoute ? "topbar-settings" : ""}`}>
45+
<div className="topbar-tabs-wrap">
46+
{isSettingsRoute ? (
47+
<div className="route-topbar" data-testid="settings-topbar">
48+
<button className="route-topbar-back" type="button" onClick={onCloseSettings}>
49+
<HeaderBackIcon />
50+
<span>{t("backToApp")}</span>
51+
</button>
52+
<div className="route-topbar-title">{t("settings")}</div>
53+
</div>
54+
) : (
55+
<div className="topbar-session-strip topbar-workspace-strip" data-testid="workspace-topbar">
56+
<button
57+
type="button"
58+
className={`session-top-history ${historyOpen ? "active" : ""}`}
59+
onClick={onToggleHistory}
60+
title={t("history")}
61+
aria-label={t("history")}
62+
data-testid="history-toggle"
63+
>
64+
<HeaderHistoryIcon />
65+
</button>
66+
{hasWorkspaceTabs ? (
67+
workspaceTabs.map((item) => (
68+
<div
69+
key={item.id}
70+
role="button"
71+
tabIndex={0}
72+
className={`session-top-tab workspace-top-tab ${item.active ? "active" : ""} ${item.hasRunning ? "running-glow" : ""}`}
73+
onClick={() => onSwitchWorkspace(item.id)}
74+
onKeyDown={(event) => {
75+
if (event.key === "Enter" || event.key === " ") {
76+
event.preventDefault();
77+
onSwitchWorkspace(item.id);
78+
}
79+
}}
80+
title={item.label}
81+
>
82+
<span className={`session-top-dot ${item.hasRunning ? "active pulse" : "idle"}`} />
83+
<span className="session-top-label">{item.label}</span>
84+
{!item.active && item.unread > 0 && (
85+
<span className="session-top-unread" title={`${item.unread}`} aria-label={`${item.unread}`}>
86+
{item.unread > 9 ? "9+" : item.unread}
87+
</span>
88+
)}
89+
<button
90+
type="button"
91+
className="session-top-close"
92+
title={t("close")}
93+
aria-label={t("close")}
94+
onClick={(event) => {
95+
event.stopPropagation();
96+
onRemoveTab(item.id);
97+
}}
98+
>
99+
<HeaderCloseIcon />
100+
</button>
101+
</div>
102+
))
103+
) : (
104+
<div className="workspace-topbar-empty" aria-live="polite">
105+
<span className="workspace-topbar-empty-kicker">{t("workspaceWelcomeKicker")}</span>
106+
<span className="workspace-topbar-empty-label">{t("noWorkspace")}</span>
107+
</div>
108+
)}
109+
<button
110+
type="button"
111+
className="session-top-add"
112+
onClick={onAddTab}
113+
title={locale === "zh" ? "新建工作区" : "Add workspace"}
114+
aria-label={locale === "zh" ? "新建工作区" : "Add workspace"}
77115
>
78-
<span className={`session-top-dot ${item.hasRunning ? "active pulse" : "idle"}`} />
79-
<span className="session-top-label">{item.label}</span>
80-
{!item.active && item.unread > 0 && (
81-
<span className="session-top-unread" title={`${item.unread}`} aria-label={`${item.unread}`}>
82-
{item.unread > 9 ? "9+" : item.unread}
83-
</span>
84-
)}
85-
<button
86-
type="button"
87-
className="session-top-close"
88-
title={t("close")}
89-
aria-label={t("close")}
90-
onClick={(event) => {
91-
event.stopPropagation();
92-
onRemoveTab(item.id);
93-
}}
94-
>
95-
<HeaderCloseIcon />
96-
</button>
97-
</div>
98-
))}
99-
<button
100-
type="button"
101-
className="session-top-add"
102-
onClick={onAddTab}
103-
title={locale === "zh" ? "新建工作区" : "Add workspace"}
104-
aria-label={locale === "zh" ? "新建工作区" : "Add workspace"}
105-
>
106-
<HeaderAddIcon />
107-
</button>
108-
</div>
109-
)}
110-
</div>
111-
<div className="topbar-actions">
112-
{!isSettingsRoute && (
113-
<>
114-
<button
115-
type="button"
116-
className="topbar-tool topbar-tool-wide"
117-
onClick={onOpenCommandPalette}
118-
title={locale === "zh" ? "快速操作(⌘/Ctrl+K)" : "Quick actions (⌘/Ctrl+K)"}
119-
aria-label={locale === "zh" ? "快速操作" : "Quick actions"}
120-
>
121-
<SearchIcon />
122-
<span>{locale === "zh" ? "操作" : "Actions"}</span>
123-
</button>
124-
<button className="topbar-tool" type="button" onClick={onOpenSettings} data-testid="settings-open" title={t("settings")} aria-label={t("settings")}>
125-
<HeaderSettingsIcon />
126-
</button>
127-
</>
128-
)}
129-
</div>
130-
</header>
131-
);
116+
<HeaderAddIcon />
117+
</button>
118+
</div>
119+
)}
120+
</div>
121+
<div className="topbar-actions">
122+
{!isSettingsRoute && (
123+
<>
124+
<button
125+
type="button"
126+
className="topbar-tool topbar-tool-wide"
127+
onClick={onOpenCommandPalette}
128+
title={locale === "zh" ? "快速操作(⌘/Ctrl+K)" : "Quick actions (⌘/Ctrl+K)"}
129+
aria-label={locale === "zh" ? "快速操作" : "Quick actions"}
130+
>
131+
<SearchIcon />
132+
<span>{locale === "zh" ? "操作" : "Actions"}</span>
133+
</button>
134+
<button className="topbar-tool" type="button" onClick={onOpenSettings} data-testid="settings-open" title={t("settings")} aria-label={t("settings")}>
135+
<HeaderSettingsIcon />
136+
</button>
137+
</>
138+
)}
139+
</div>
140+
</header>
141+
);
142+
};
132143

133144
export default TopBar;

apps/web/src/components/WorkspaceLaunchOverlay/WorkspaceLaunchOverlay.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { useEffect } from "react";
12
import type { Translator } from "../../i18n";
23
import type { ExecTarget } from "../../state/workbench";
34
import type { FolderBrowserState } from "../../types/app";
4-
import { ChevronRightIcon, WorkspaceFolderIcon } from "../icons";
5+
import { ChevronRightIcon, HeaderCloseIcon, WorkspaceFolderIcon } from "../icons";
56

67
type WorkspaceLaunchOverlayProps = {
78
visible: boolean;
@@ -11,6 +12,7 @@ type WorkspaceLaunchOverlayProps = {
1112
folderBrowser: FolderBrowserState;
1213
onUpdateTarget: (target: ExecTarget) => void;
1314
onBrowseDirectory: (path?: string, selectCurrent?: boolean) => void;
15+
onClose: () => void;
1416
onStartWorkspace: () => void;
1517
t: Translator;
1618
};
@@ -23,9 +25,25 @@ export const WorkspaceLaunchOverlay = ({
2325
folderBrowser,
2426
onUpdateTarget,
2527
onBrowseDirectory,
28+
onClose,
2629
onStartWorkspace,
2730
t
2831
}: WorkspaceLaunchOverlayProps) => {
32+
useEffect(() => {
33+
if (!visible) return undefined;
34+
35+
const onKeyDown = (event: KeyboardEvent) => {
36+
if (event.key === "Escape") {
37+
onClose();
38+
}
39+
};
40+
41+
window.addEventListener("keydown", onKeyDown);
42+
return () => {
43+
window.removeEventListener("keydown", onKeyDown);
44+
};
45+
}, [visible, onClose]);
46+
2947
if (!visible) return null;
3048

3149
const selectedPath = input.trim();
@@ -34,23 +52,40 @@ export const WorkspaceLaunchOverlay = ({
3452
);
3553

3654
return (
37-
<div className="overlay" data-testid="overlay">
38-
<div className="modal onboarding-modal launch-overlay-shell" data-testid="launch-overlay-shell" data-density="compact">
55+
<div className="overlay" data-testid="overlay" onClick={onClose}>
56+
<div
57+
className="modal onboarding-modal launch-overlay-shell"
58+
data-testid="launch-overlay-shell"
59+
data-density="compact"
60+
onClick={(event) => event.stopPropagation()}
61+
>
3962
<div className="onboarding-form">
4063
<div className="onboarding-header launch-overlay-header">
4164
<div className="launch-overlay-copy">
4265
<span className="section-kicker">{t("startWorkspace")}</span>
4366
<h2>{t("localFolder")}</h2>
4467
<p>{t("localFolderHint")}</p>
4568
</div>
46-
<div className="launch-overlay-meta">
47-
<div className="launch-overlay-meta-item">
48-
<span className="section-kicker">{t("selected")}</span>
49-
<strong>{selectedPath || folderBrowser.currentPath || t("loading")}</strong>
50-
</div>
51-
<div className="launch-overlay-meta-item">
52-
<span className="section-kicker">{target.type === "wsl" ? "WSL" : t("nativeTarget")}</span>
53-
<strong>{target.type === "wsl" ? (target.distro?.trim() || (t("nativeTarget") === "Native" ? "Default distro" : "默认发行版")) : t("nativeTarget")}</strong>
69+
<div className="launch-overlay-header-actions">
70+
<button
71+
type="button"
72+
className="launch-overlay-close"
73+
onClick={onClose}
74+
aria-label={t("close")}
75+
title={t("close")}
76+
data-testid="launch-overlay-close"
77+
>
78+
<HeaderCloseIcon />
79+
</button>
80+
<div className="launch-overlay-meta">
81+
<div className="launch-overlay-meta-item">
82+
<span className="section-kicker">{t("selected")}</span>
83+
<strong>{selectedPath || folderBrowser.currentPath || t("loading")}</strong>
84+
</div>
85+
<div className="launch-overlay-meta-item">
86+
<span className="section-kicker">{target.type === "wsl" ? "WSL" : t("nativeTarget")}</span>
87+
<strong>{target.type === "wsl" ? (target.distro?.trim() || (t("nativeTarget") === "Native" ? "Default distro" : "默认发行版")) : t("nativeTarget")}</strong>
88+
</div>
5489
</div>
5590
</div>
5691
</div>

0 commit comments

Comments
 (0)