Skip to content

Commit 7584ccf

Browse files
authored
feat(ui): make open workspace action explicit (NeuralNomadsAI#600)
## Summary - turn the existing `Open` badge into a prominent action that returns directly to the running workspace - make the rest of every recent-folder row consistently start a new instance - remove the repeated already-open choice dialog and its unused translations ## Why Choosing between the existing workspace and another instance on every activation adds avoidable cognitive load. The two intentions are now represented directly by separate, stable targets: `Open` resumes existing work, while the folder row starts fresh. ## Details - force recent-row activations to request a new instance even during hydration or workspace-creation races - keep Browse, drag/drop, and clone fallback reuse behavior, but switch directly without a dialog - preserve the exact recent-folder alias when returning to an existing instance - keep native Enter/Space behavior on focused controls while preserving list and browse shortcuts - use project-specific accessible names and semantic button/focus tokens ## Validation - `npm run typecheck --workspace @codenomad/ui` - `npm run build --workspace @codenomad/ui` - `bun test --conditions=browser packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/session-pagination-model.test.ts` - `git diff --check upstream/dev...HEAD` - independent gatekeeper review: PASS
1 parent 18564d4 commit 7584ccf

12 files changed

Lines changed: 183 additions & 112 deletions

File tree

packages/ui/src/App.tsx

Lines changed: 12 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import {
3535
import { recentFolders, useConfig } from "./stores/preferences"
3636
import {
3737
createInstance,
38-
getExistingInstanceForFolder,
3938
instances,
4039
stopInstance,
4140
disconnectedInstance,
@@ -102,11 +101,6 @@ const App: Component = () => {
102101
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
103102
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
104103
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
105-
const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{
106-
folderPath: string
107-
binaryPath: string
108-
instanceId: string
109-
} | null>(null)
110104
const phoneQuery = useMediaQuery("(max-width: 767px)")
111105
const isPhoneLayout = createMemo(() => phoneQuery())
112106

@@ -288,25 +282,17 @@ const App: Component = () => {
288282
const projectName = getProjectNameForFolder(folderPath)
289283
clearLaunchError()
290284

291-
if (!options?.forceNew) {
292-
const existingInstance = getExistingInstanceForFolder(folderPath)
293-
if (existingInstance) {
294-
recordWorkspaceLaunch(existingInstance.folder, selectedBinary, folderPath)
295-
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id })
296-
return
297-
}
298-
}
299-
300285
setIsSelectingFolder(true)
301286
try {
302287
const result = await createInstance(folderPath, selectedBinary, projectName, { forceNew: options?.forceNew })
288+
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
303289
if (result.reused) {
304-
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
305-
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: result.instanceId })
290+
selectInstanceTab(result.instanceId)
291+
setShowFolderSelection(false)
292+
log.info("Selected reused instance", { instanceId: result.instanceId, folderPath })
306293
return
307294
}
308295

309-
recordWorkspaceLaunch(instances().get(result.instanceId)?.folder ?? folderPath, selectedBinary, folderPath)
310296
selectInstanceTab(result.instanceId)
311297
setShowFolderSelection(false)
312298

@@ -324,24 +310,13 @@ const App: Component = () => {
324310
}
325311
}
326312

327-
function dismissAlreadyOpenFolderChoice() {
328-
setAlreadyOpenFolderChoice(null)
329-
}
330-
331-
function switchToAlreadyOpenFolder() {
332-
const choice = alreadyOpenFolderChoice()
333-
if (!choice) return
334-
setAlreadyOpenFolderChoice(null)
335-
selectInstanceTab(choice.instanceId)
313+
function handleSelectExistingInstance(instanceId: string, recentPath: string, binaryPath: string) {
314+
const instance = instances().get(instanceId)
315+
if (!instance) return
316+
recordWorkspaceLaunch(instance.folder, binaryPath, recentPath)
317+
selectInstanceTab(instanceId)
336318
setShowFolderSelection(false)
337-
log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath })
338-
}
339-
340-
function openAnotherFolderInstance() {
341-
const choice = alreadyOpenFolderChoice()
342-
if (!choice) return
343-
setAlreadyOpenFolderChoice(null)
344-
void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true })
319+
log.info("Selected existing instance", { instanceId, folderPath: instance.folder })
345320
}
346321

347322
function handleLaunchErrorClose() {
@@ -655,6 +630,7 @@ const App: Component = () => {
655630
>
656631
<FolderSelectionView
657632
onSelectFolder={handleSelectFolder}
633+
onSelectExistingInstance={handleSelectExistingInstance}
658634
isLoading={isSelectingFolder()}
659635
onOpenSidecar={handleOpenSidecarPicker}
660636
/>
@@ -665,6 +641,7 @@ const App: Component = () => {
665641
<div class="w-full h-full relative">
666642
<FolderSelectionView
667643
onSelectFolder={handleSelectFolder}
644+
onSelectExistingInstance={handleSelectExistingInstance}
668645
isLoading={isSelectingFolder()}
669646
onOpenSidecar={handleOpenSidecarPicker}
670647
onClose={() => {
@@ -678,31 +655,6 @@ const App: Component = () => {
678655

679656
<SettingsScreen />
680657
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
681-
<Show when={alreadyOpenFolderChoice()}>
682-
<Dialog open modal onOpenChange={(open) => !open && dismissAlreadyOpenFolderChoice()}>
683-
<Dialog.Portal>
684-
<Dialog.Overlay class="modal-overlay z-[60]" />
685-
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
686-
<Dialog.Title class="text-lg font-semibold text-primary">
687-
{t("folderSelection.recent.alreadyOpenTitle")}
688-
</Dialog.Title>
689-
<Dialog.Description class="text-sm text-secondary mt-1">
690-
{t("folderSelection.recent.alreadyOpenMessage")}
691-
</Dialog.Description>
692-
693-
<div class="mt-6 flex justify-end gap-3">
694-
<button type="button" class="button-secondary" onClick={openAnotherFolderInstance}>
695-
{t("folderSelection.recent.openAnotherInstance")}
696-
</button>
697-
<button type="button" class="button-primary" onClick={switchToAlreadyOpenFolder}>
698-
{t("folderSelection.recent.switchToOpenProject")}
699-
</button>
700-
</div>
701-
</Dialog.Content>
702-
</Dialog.Portal>
703-
</Dialog>
704-
</Show>
705-
706658
<AlertDialog />
707659

708660
<Toaster

packages/ui/src/components/folder-selection-view.tsx

Lines changed: 105 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type HomeTab = "local" | "servers"
3030

3131
interface FolderSelectionViewProps {
3232
onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void
33+
onSelectExistingInstance: (instanceId: string, recentPath: string, binaryPath: string) => void
3334
onOpenSidecar?: () => void
3435
isLoading?: boolean
3536
onClose?: () => void
@@ -50,6 +51,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
5051
} = useConfig()
5152
const { t, locale } = useI18n()
5253
const [selectedIndex, setSelectedIndex] = createSignal(0)
54+
const [hoveredRecentActionPath, setHoveredRecentActionPath] = createSignal<string | null>(null)
55+
const [focusedRecentActionPath, setFocusedRecentActionPath] = createSignal<string | null>(null)
5356
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
5457
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
5558
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
@@ -133,6 +136,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
133136
const isEditingField =
134137
activeElement &&
135138
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
139+
const isInteractiveControl = activeElement && ["BUTTON", "A"].includes(activeElement.tagName)
136140

137141
if (isEditingField) {
138142
return
@@ -155,6 +159,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
155159
return
156160
}
157161

162+
if (isInteractiveControl && (e.key === "Enter" || e.key === " ")) return
163+
158164
const listLength = getActiveListLength()
159165
if (listLength === 0) return
160166

@@ -209,7 +215,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
209215
if (activeTab() === "local") {
210216
const folder = folders()[index]
211217
if (folder) {
212-
handleFolderSelect(folder.path)
218+
handleFolderSelect(folder.path, true)
213219
}
214220
return
215221
}
@@ -309,9 +315,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
309315
return t("time.relative.justNow")
310316
}
311317

312-
function handleFolderSelect(path: string) {
318+
function handleFolderSelect(path: string, forceNew = false) {
319+
if (isLoading()) return
320+
props.onSelectFolder(path, selectedBinary(), forceNew ? { forceNew: true } : undefined)
321+
}
322+
323+
function handleExistingInstanceSelect(instanceId: string, recentPath: string) {
313324
if (isLoading()) return
314-
props.onSelectFolder(path, selectedBinary())
325+
props.onSelectExistingInstance(instanceId, recentPath, selectedBinary())
326+
}
327+
328+
function setRecentActionHovered(path: string, active: boolean) {
329+
setHoveredRecentActionPath((current) => active ? path : current === path ? null : current)
330+
}
331+
332+
function setRecentActionFocused(path: string, active: boolean) {
333+
setFocusedRecentActionPath((current) => active ? path : current === path ? null : current)
334+
}
335+
336+
function clearRecentActionState(path: string) {
337+
setRecentActionHovered(path, false)
338+
setRecentActionFocused(path, false)
315339
}
316340

317341
function resetCloneDialog() {
@@ -924,37 +948,82 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
924948
<For each={folders()}>
925949
{(folder, index) => {
926950
const existingInstance = () => getExistingInstanceForFolder(folder.path)
951+
const projectName = () => getProjectDisplayName(folder.path, folder.projectName)
952+
const projectLabelId = () => `recent-folder-${index()}-name`
953+
const openActionLabelId = () => `recent-folder-${index()}-open-action`
927954

928955
return <div
929-
class="panel-list-item"
956+
class="panel-list-item folder-home-recent-item"
930957
classList={{
931958
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
932959
"panel-list-item-disabled": isLoading(),
960+
"folder-home-recent-item-action-active":
961+
hoveredRecentActionPath() === folder.path || focusedRecentActionPath() === folder.path,
962+
}}
963+
onMouseEnter={() => {
964+
if (isLoading()) return
965+
setFocusMode("recent")
966+
setSelectedIndex(index())
933967
}}
934968
>
935969
<div class="flex items-center gap-2 w-full px-1">
936-
<button
937-
data-list-index={index()}
938-
class="panel-list-item-content flex-1"
939-
disabled={isLoading()}
940-
onClick={() => handleFolderSelect(folder.path)}
941-
onMouseEnter={() => {
942-
if (isLoading()) return
943-
setFocusMode("recent")
944-
setSelectedIndex(index())
945-
}}
946-
>
947-
<div class="flex items-center justify-between gap-3 w-full">
970+
<div class="panel-list-item-content relative flex-1">
971+
<button
972+
data-list-index={index()}
973+
type="button"
974+
class="folder-home-recent-primary-action"
975+
disabled={isLoading()}
976+
aria-labelledby={projectLabelId()}
977+
onClick={() => handleFolderSelect(folder.path, true)}
978+
onFocus={() => {
979+
setFocusMode("recent")
980+
setSelectedIndex(index())
981+
}}
982+
/>
983+
<div class="relative z-[1] pointer-events-none flex items-center justify-between gap-3 w-full">
948984
<div class="flex-1 min-w-0">
949985
<div class="flex items-center gap-2 mb-1">
950986
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
951-
<span class="text-sm font-medium truncate text-primary">
952-
{getProjectDisplayName(folder.path, folder.projectName)}
987+
<span id={projectLabelId()} class="text-sm font-medium truncate text-primary">
988+
{projectName()}
953989
</span>
954990
<Show when={existingInstance()}>
955-
<span class="rounded-full border border-base px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary">
956-
{t("folderSelection.recent.openBadge")}
957-
</span>
991+
{(instance) => {
992+
let ownsHoverState = false
993+
let ownsFocusState = false
994+
onCleanup(() => {
995+
if (ownsHoverState) setRecentActionHovered(folder.path, false)
996+
if (ownsFocusState) setRecentActionFocused(folder.path, false)
997+
})
998+
return (
999+
<button
1000+
type="button"
1001+
class="folder-home-open-instance-button folder-home-row-action pointer-events-auto"
1002+
disabled={isLoading()}
1003+
aria-labelledby={`${openActionLabelId()} ${projectLabelId()}`}
1004+
title={t("folderSelection.recent.switchToOpenProject")}
1005+
onClick={() => handleExistingInstanceSelect(instance().id, folder.path)}
1006+
onMouseEnter={() => {
1007+
ownsHoverState = true
1008+
setRecentActionHovered(folder.path, true)
1009+
}}
1010+
onMouseLeave={() => {
1011+
ownsHoverState = false
1012+
setRecentActionHovered(folder.path, false)
1013+
}}
1014+
onFocus={() => {
1015+
ownsFocusState = true
1016+
setRecentActionFocused(folder.path, true)
1017+
}}
1018+
onBlur={() => {
1019+
ownsFocusState = false
1020+
setRecentActionFocused(folder.path, false)
1021+
}}
1022+
>
1023+
<span id={openActionLabelId()}>{t("folderSelection.recent.openBadge")}</span>
1024+
</button>
1025+
)
1026+
}}
9581027
</Show>
9591028
</div>
9601029
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
@@ -968,20 +1037,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
9681037
<kbd class="kbd"></kbd>
9691038
</Show>
9701039
</div>
971-
</button>
1040+
</div>
9721041
<button
9731042
onClick={(e) => openProjectRename(folder.path, folder.projectName, e)}
9741043
disabled={isLoading()}
975-
class="p-2 transition-all hover:bg-surface-hover opacity-70 hover:opacity-100 rounded"
1044+
class="folder-home-row-action p-2 transition-all hover:bg-surface-hover opacity-70 hover:opacity-100 rounded"
9761045
title={t("folderSelection.recent.rename")}
1046+
onMouseEnter={() => setRecentActionHovered(folder.path, true)}
1047+
onMouseLeave={() => setRecentActionHovered(folder.path, false)}
1048+
onFocus={() => setRecentActionFocused(folder.path, true)}
1049+
onBlur={() => setRecentActionFocused(folder.path, false)}
9771050
>
9781051
<Pencil class="w-3.5 h-3.5 transition-colors icon-muted" />
9791052
</button>
9801053
<button
981-
onClick={(e) => handleRemove(folder.path, e)}
1054+
onClick={(e) => {
1055+
clearRecentActionState(folder.path)
1056+
handleRemove(folder.path, e)
1057+
}}
9821058
disabled={isLoading()}
983-
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
1059+
class="folder-home-row-action p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
9841060
title={t("folderSelection.recent.remove")}
1061+
onMouseEnter={() => setRecentActionHovered(folder.path, true)}
1062+
onMouseLeave={() => setRecentActionHovered(folder.path, false)}
1063+
onFocus={() => setRecentActionFocused(folder.path, true)}
1064+
onBlur={() => setRecentActionFocused(folder.path, false)}
9851065
>
9861066
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
9871067
</button>

packages/ui/src/lib/i18n/messages/de/folderSelection.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ export const folderSelectionMessages = {
1717
"folderSelection.recent.rename": "Arbeitsbereich umbenennen",
1818
"folderSelection.recent.remove": "Aus Liste entfernen",
1919
"folderSelection.recent.openBadge": "Offen",
20-
"folderSelection.recent.alreadyOpenTitle": "Projekt bereits offen",
21-
"folderSelection.recent.alreadyOpenMessage": "Wählen Sie, wie dieser Ordner geöffnet werden soll.",
2220
"folderSelection.recent.switchToOpenProject": "Zum offenen Projekt wechseln",
23-
"folderSelection.recent.openAnotherInstance": "Weitere Instanz öffnen",
2421

2522
"folderSelection.browse.title": "Nach Ordner suchen",
2623
"folderSelection.browse.subtitle": "Wählen Sie einen beliebigen Ordner auf Ihrem Computer aus",

packages/ui/src/lib/i18n/messages/en/folderSelection.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@ export const folderSelectionMessages = {
1717
"folderSelection.recent.rename": "Rename workspace",
1818
"folderSelection.recent.remove": "Remove from recent",
1919
"folderSelection.recent.openBadge": "Open",
20-
"folderSelection.recent.alreadyOpenTitle": "Project already open",
21-
"folderSelection.recent.alreadyOpenMessage": "Choose how to open this folder.",
2220
"folderSelection.recent.switchToOpenProject": "Switch to open project",
23-
"folderSelection.recent.openAnotherInstance": "Open another instance",
2421

2522
"folderSelection.browse.title": "Browse for Folder",
2623
"folderSelection.browse.subtitle": "Select any folder on your computer",

0 commit comments

Comments
 (0)