fix(ui): return to active projects from home (#411)

## Summary
- Return to an existing open project when users choose that from the
already-open folder dialog.
- When users click a recent folder that is already open, ask whether to
switch to the open project or intentionally open another instance.
- Surface a prominent Return to Active Project action so leaving the
home screen no longer depends on the subtle top-right close affordance.
- Keep multi-instance workflows available without requiring users to
remember a separate row-level button.

Fixes #281

## Why
Users can accidentally open the same workspace several times from the
add-project screen. The app now detects that ambiguity when the
already-open folder is clicked and asks what should happen, instead of
making the user remember a special alternate button ahead of time.

The dialog keeps the copy short: the title states that the project is
already open, and the buttons carry the actual choices.

## Verification
- `git diff --check`
- `npm run typecheck --workspace @codenomad/ui`
- `npm run build --workspace @codenomad/ui`
- `npm run build:tauri`
This commit is contained in:
Pascal André 2026-05-10 19:36:49 +02:00 committed by GitHub
parent 562c6325c7
commit a3231c3da5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 175 additions and 10 deletions

View file

@ -34,6 +34,7 @@ import {
import { useConfig } from "./stores/preferences"
import {
createInstance,
getExistingInstanceForFolder,
instances,
stopInstance,
disconnectedInstance,
@ -95,6 +96,11 @@ const App: Component = () => {
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false)
const [alreadyOpenFolderChoice, setAlreadyOpenFolderChoice] = createSignal<{
folderPath: string
binaryPath: string
instanceId: string
} | null>(null)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
@ -258,15 +264,24 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? ""
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
async function handleSelectFolder(folderPath: string, binaryPath?: string, options?: { forceNew?: boolean }) {
if (!folderPath) {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
if (!options?.forceNew) {
const existingInstance = getExistingInstanceForFolder(folderPath)
if (existingInstance) {
setAlreadyOpenFolderChoice({ folderPath, binaryPath: selectedBinary, instanceId: existingInstance.id })
return
}
}
setIsSelectingFolder(true)
try {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
selectInstanceTab(instanceId)
setShowFolderSelection(false)
@ -285,6 +300,26 @@ const App: Component = () => {
}
}
function dismissAlreadyOpenFolderChoice() {
setAlreadyOpenFolderChoice(null)
}
function switchToAlreadyOpenFolder() {
const choice = alreadyOpenFolderChoice()
if (!choice) return
setAlreadyOpenFolderChoice(null)
selectInstanceTab(choice.instanceId)
setShowFolderSelection(false)
log.info("Selected existing instance", { instanceId: choice.instanceId, folderPath: choice.folderPath })
}
function openAnotherFolderInstance() {
const choice = alreadyOpenFolderChoice()
if (!choice) return
setAlreadyOpenFolderChoice(null)
void handleSelectFolder(choice.folderPath, choice.binaryPath, { forceNew: true })
}
function handleLaunchErrorClose() {
clearLaunchError()
}
@ -609,6 +644,7 @@ const App: Component = () => {
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
onOpenSidecar={handleOpenSidecarPicker}
activeProjectLabel={activeInstance()?.folder ?? null}
onClose={() => {
setShowFolderSelection(false)
clearLaunchError()
@ -620,6 +656,30 @@ const App: Component = () => {
<SettingsScreen />
<SideCarPickerDialog open={sidecarPickerOpen()} onClose={() => setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} />
<Show when={alreadyOpenFolderChoice()}>
<Dialog open modal onOpenChange={(open) => !open && dismissAlreadyOpenFolderChoice()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay z-[60]" />
<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}>
<Dialog.Title class="text-lg font-semibold text-primary">
{t("folderSelection.recent.alreadyOpenTitle")}
</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{t("folderSelection.recent.alreadyOpenMessage")}
</Dialog.Description>
<div class="mt-6 flex justify-end gap-3">
<button type="button" class="button-secondary" onClick={openAnotherFolderInstance}>
{t("folderSelection.recent.openAnotherInstance")}
</button>
<button type="button" class="button-primary" onClick={switchToAlreadyOpenFolder}>
{t("folderSelection.recent.switchToOpenProject")}
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
</Show>
<AlertDialog />

View file

@ -1,7 +1,7 @@
import { Dialog } from "@kobalte/core/dialog"
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch } from "lucide-solid"
import { Component, createMemo, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X, Globe, Loader2, GitBranch, ArrowLeft } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
@ -18,6 +18,7 @@ import { openExternalUrl } from "../lib/external-url"
import { serverApi } from "../lib/api-client"
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
import { openRemoteServerWindow } from "../lib/native/remote-window"
import { getExistingInstanceForFolder } from "../stores/instances"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
@ -27,10 +28,11 @@ type HomeTab = "local" | "servers"
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
onSelectFolder: (folder: string, binaryPath?: string, options?: { forceNew?: boolean }) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
activeProjectLabel?: string | null
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
@ -85,6 +87,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const serverList = () => remoteServers()
const isLoading = () => Boolean(props.isLoading)
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
const activeProjectName = createMemo(() => {
const label = props.activeProjectLabel?.trim()
if (!label) return null
return splitFolderPath(label).baseName
})
function getActiveListLength() {
return activeTab() === "local" ? folders().length : serverList().length
@ -862,8 +869,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
ref={(el) => (recentListRef = el)}
>
<For each={folders()}>
{(folder, index) => (
<div
{(folder, index) => {
const existingInstance = () => getExistingInstanceForFolder(folder.path)
return <div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
@ -889,6 +898,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<span class="text-sm font-medium truncate text-primary">
{splitFolderPath(folder.path).baseName}
</span>
<Show when={existingInstance()}>
<span class="rounded-full border border-base px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary">
{t("folderSelection.recent.openBadge")}
</span>
</Show>
</div>
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
@ -912,7 +926,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
</div>
</div>
)}
}}
</For>
</div>
</Show>
@ -947,6 +961,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<Show when={props.onClose}>
<button
type="button"
onClick={() => props.onClose?.()}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<ArrowLeft class="w-4 h-4" />
<span>{t("folderSelection.actions.returnToProject")}</span>
</div>
</button>
</Show>
<Show when={activeProjectName()}>
{(name) => <p class="text-xs text-muted text-center">{t("folderSelection.actions.returnToProjectSubtitle", { name: name() })}</p>}
</Show>
<button
type="button"
onClick={openCloneDialog}

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "{count} folder available",
"folderSelection.recent.subtitle.other": "{count} folders available",
"folderSelection.recent.remove": "Remove from recent",
"folderSelection.recent.openBadge": "Open",
"folderSelection.recent.alreadyOpenTitle": "Project already open",
"folderSelection.recent.alreadyOpenMessage": "Choose how to open this folder.",
"folderSelection.recent.switchToOpenProject": "Switch to open project",
"folderSelection.recent.openAnotherInstance": "Open another instance",
"folderSelection.browse.title": "Browse for Folder",
"folderSelection.browse.subtitle": "Select any folder on your computer",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Open Folder or Connect Server",
"folderSelection.actions.subtitle": "Open local folder or connect to a CodeNomad server",
"folderSelection.actions.connectButton": "Connect CodeNomad Server",
"folderSelection.actions.returnToProject": "Return to Active Project",
"folderSelection.actions.returnToProjectSubtitle": "Go back to {name}",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "{count} carpeta disponible",
"folderSelection.recent.subtitle.other": "{count} carpetas disponibles",
"folderSelection.recent.remove": "Eliminar de recientes",
"folderSelection.recent.openBadge": "Abierta",
"folderSelection.recent.alreadyOpenTitle": "Proyecto ya abierto",
"folderSelection.recent.alreadyOpenMessage": "Elige cómo abrir esta carpeta.",
"folderSelection.recent.switchToOpenProject": "Cambiar al proyecto abierto",
"folderSelection.recent.openAnotherInstance": "Abrir otra instancia",
"folderSelection.browse.title": "Buscar carpeta",
"folderSelection.browse.subtitle": "Selecciona cualquier carpeta en tu ordenador",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Abrir carpeta o conectar servidor",
"folderSelection.actions.subtitle": "Abre una carpeta local o conéctate a un servidor de CodeNomad",
"folderSelection.actions.connectButton": "Conectar servidor CodeNomad",
"folderSelection.actions.returnToProject": "Volver al proyecto activo",
"folderSelection.actions.returnToProjectSubtitle": "Volver a {name}",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "{count} dossier disponible",
"folderSelection.recent.subtitle.other": "{count} dossiers disponibles",
"folderSelection.recent.remove": "Retirer des récents",
"folderSelection.recent.openBadge": "Ouvert",
"folderSelection.recent.alreadyOpenTitle": "Projet déjà ouvert",
"folderSelection.recent.alreadyOpenMessage": "Choisissez comment ouvrir ce dossier.",
"folderSelection.recent.switchToOpenProject": "Basculer vers le projet ouvert",
"folderSelection.recent.openAnotherInstance": "Ouvrir une autre instance",
"folderSelection.browse.title": "Parcourir un dossier",
"folderSelection.browse.subtitle": "Sélectionnez n'importe quel dossier sur votre ordinateur",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Ouvrir un dossier ou se connecter à un serveur",
"folderSelection.actions.subtitle": "Ouvrez un dossier local ou connectez-vous à un serveur CodeNomad",
"folderSelection.actions.connectButton": "Se connecter au serveur CodeNomad",
"folderSelection.actions.returnToProject": "Revenir au projet actif",
"folderSelection.actions.returnToProjectSubtitle": "Retourner vers {name}",
"folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "תיקייה אחת זמינה",
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
"folderSelection.recent.openBadge": "פתוח",
"folderSelection.recent.alreadyOpenTitle": "הפרויקט כבר פתוח",
"folderSelection.recent.alreadyOpenMessage": "בחר כיצד לפתוח את התיקייה הזו.",
"folderSelection.recent.switchToOpenProject": "עבור לפרויקט הפתוח",
"folderSelection.recent.openAnotherInstance": "פתח מופע נוסף",
"folderSelection.browse.title": "עיון בתיקייה",
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "פתח תיקייה או התחבר לשרת",
"folderSelection.actions.subtitle": "פתח תיקייה מקומית או התחבר לשרת CodeNomad",
"folderSelection.actions.connectButton": "התחבר לשרת CodeNomad",
"folderSelection.actions.returnToProject": "חזור לפרויקט הפעיל",
"folderSelection.actions.returnToProjectSubtitle": "חזור אל {name}",
"folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "{count} 件のフォルダ",
"folderSelection.recent.subtitle.other": "{count} 件のフォルダ",
"folderSelection.recent.remove": "履歴から削除",
"folderSelection.recent.openBadge": "開いています",
"folderSelection.recent.alreadyOpenTitle": "プロジェクトはすでに開いています",
"folderSelection.recent.alreadyOpenMessage": "このフォルダの開き方を選択してください。",
"folderSelection.recent.switchToOpenProject": "開いているプロジェクトに切り替え",
"folderSelection.recent.openAnotherInstance": "別のインスタンスを開く",
"folderSelection.browse.title": "フォルダを参照",
"folderSelection.browse.subtitle": "コンピュータ上の任意のフォルダを選択",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "フォルダを開くかサーバーに接続",
"folderSelection.actions.subtitle": "ローカルフォルダを開くか CodeNomad サーバーに接続します",
"folderSelection.actions.connectButton": "CodeNomad サーバーに接続",
"folderSelection.actions.returnToProject": "アクティブなプロジェクトに戻る",
"folderSelection.actions.returnToProjectSubtitle": "{name} に戻る",
"folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "Доступна {count} папка",
"folderSelection.recent.subtitle.other": "Доступно {count} папок",
"folderSelection.recent.remove": "Убрать из недавних",
"folderSelection.recent.openBadge": "Открыта",
"folderSelection.recent.alreadyOpenTitle": "Проект уже открыт",
"folderSelection.recent.alreadyOpenMessage": "Выберите, как открыть эту папку.",
"folderSelection.recent.switchToOpenProject": "Перейти к открытому проекту",
"folderSelection.recent.openAnotherInstance": "Открыть еще одну инстанцию",
"folderSelection.browse.title": "Выбрать папку",
"folderSelection.browse.subtitle": "Выберите любую папку на компьютере",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "Открыть папку или подключить сервер",
"folderSelection.actions.subtitle": "Откройте локальную папку или подключитесь к серверу CodeNomad",
"folderSelection.actions.connectButton": "Подключить сервер CodeNomad",
"folderSelection.actions.returnToProject": "Вернуться к активному проекту",
"folderSelection.actions.returnToProjectSubtitle": "Вернуться к {name}",
"folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode",

View file

@ -15,6 +15,11 @@ export const folderSelectionMessages = {
"folderSelection.recent.subtitle.one": "{count} 个文件夹可用",
"folderSelection.recent.subtitle.other": "{count} 个文件夹可用",
"folderSelection.recent.remove": "从最近列表移除",
"folderSelection.recent.openBadge": "已打开",
"folderSelection.recent.alreadyOpenTitle": "项目已打开",
"folderSelection.recent.alreadyOpenMessage": "选择如何打开此文件夹。",
"folderSelection.recent.switchToOpenProject": "切换到已打开的项目",
"folderSelection.recent.openAnotherInstance": "打开另一个实例",
"folderSelection.browse.title": "浏览文件夹",
"folderSelection.browse.subtitle": "选择你电脑上的任意文件夹",
@ -38,6 +43,8 @@ export const folderSelectionMessages = {
"folderSelection.actions.title": "打开文件夹或连接服务器",
"folderSelection.actions.subtitle": "打开本地文件夹或连接到 CodeNomad 服务器",
"folderSelection.actions.connectButton": "连接 CodeNomad 服务器",
"folderSelection.actions.returnToProject": "返回当前项目",
"folderSelection.actions.returnToProjectSubtitle": "返回到 {name}",
"folderSelection.advancedSettings": "高级设置",
"folderSelection.opencode": "OpenCode",

View file

@ -553,6 +553,30 @@ async function createInstance(folder: string, _binaryPath?: string): Promise<str
}
}
function normalizeInstanceFolderPath(folder: string): string {
const trimmed = folder.replace(/[\\/]+$/, "")
const windowsLike = /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(trimmed)
if (!windowsLike) {
return trimmed
}
return trimmed.replace(/\\/g, "/").toLowerCase()
}
function getExistingInstanceForFolder(folder: string): Instance | null {
if (!folder) return null
const target = normalizeInstanceFolderPath(folder)
const matches = Array.from(instances().values()).filter((instance) => {
if (instance.status === "stopped") return false
return normalizeInstanceFolderPath(instance.folder) === target
})
if (matches.length === 0) return null
const activeId = activeInstanceId()
return matches.find((instance) => instance.id === activeId) ?? matches.find((instance) => instance.status === "ready") ?? matches[0] ?? null
}
async function stopInstance(id: string) {
const instance = instances().get(id)
if (!instance) return
@ -1169,6 +1193,7 @@ export {
updateInstance,
removeInstance,
createInstance,
getExistingInstanceForFolder,
stopInstance,
getActiveInstance,
addLog,