fix(web): unify Add Project URL/path classification across UI entry points

Settings → Projects Add Project only submitted { path }, so GitHub URLs
entered there failed even though the API and the Sidebar Add Project
already accepted them. Closes #1108.

Changes:
- Add packages/web/src/lib/codebase-input.ts: shared getCodebaseInput()
  helper returning a discriminated { path } | { url } union (re-exported
  from api.ts for convenience).
- Use the helper from all three Add Project entry points: Sidebar,
  Settings, and ChatPage. Removes three divergent inline heuristics.
- SettingsPage: rename addPath → addValue (state now holds either URL
  or local path) and update placeholder text.
- Tests: cover https://, git@ shorthand, ssh://, git://, whitespace,
  unix/relative/home/Windows/UNC paths.
- Docs: document the unified Add Project entry point in adapters/web.md.

Heuristic flips from "assume URL unless explicitly local" to "assume
local unless explicitly remote" — only inputs starting with https?://,
ssh://, git@, or git:// are sent as { url }; everything else is sent
as { path }. The server already resolves tilde/relative paths.

Co-authored-by: Nguyen Huu Loc <lockbkbang@gmail.com>
This commit is contained in:
Leex 2026-04-16 23:43:19 +02:00
parent 86e4c8d605
commit 9dd57b2f3c
7 changed files with 95 additions and 26 deletions

View file

@ -81,7 +81,7 @@ Accessible via the `/dashboard` route, the Command Center shows all workflow run
### Settings
The `/settings` page lets you configure assistant defaults (model, provider) without editing YAML files.
The `/settings` page lets you configure assistant defaults (model, provider) without editing YAML files. It also includes a **Projects** section for registering and managing codebases.
## Chat Interface
@ -203,10 +203,11 @@ A separate dashboard SSE stream at `/api/stream/__dashboard__` multiplexes workf
### Registering a Project
From the Web UI, you can register codebases in two ways:
From the Web UI, you can register codebases in three ways:
1. **Clone from URL** -- Use the `/clone <url>` command in chat, or use the API to POST to `/api/codebases` with a `url` field
2. **Register a local path** -- POST to `/api/codebases` with a `path` field pointing to an existing git repository
1. **Add Project input** -- Click **+** in the sidebar or go to **Settings → Projects** and enter a GitHub URL or local path. Inputs starting with `https://`, `ssh://`, `git@`, or `git://` are treated as remote URLs (cloned); everything else is treated as a local path (registered in place).
2. **Clone from URL via chat** -- Use the `/clone <url>` command in chat, or use the API to POST to `/api/codebases` with a `url` field
3. **Register a local path via API** -- POST to `/api/codebases` with a `path` field pointing to an existing git repository
Registered codebases appear in the sidebar's project selector.

View file

@ -11,7 +11,7 @@ import { ProjectDetail } from '@/components/sidebar/ProjectDetail';
import { AllConversationsView } from '@/components/sidebar/AllConversationsView';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { useProject } from '@/contexts/ProjectContext';
import { addCodebase } from '@/lib/api';
import { addCodebase, getCodebaseInput } from '@/lib/api';
const SIDEBAR_MIN = 240;
const SIDEBAR_MAX = 400;
@ -120,12 +120,7 @@ export function Sidebar(): React.ReactElement {
setAddLoading(true);
setAddError(null);
// Detect: starts with / or ~ or Windows drive letter → local path; otherwise → URL
const isLocalPath =
trimmed.startsWith('/') || trimmed.startsWith('~') || /^[A-Za-z]:[/\\]/.test(trimmed);
const input = isLocalPath ? { path: trimmed } : { url: trimmed };
void addCodebase(input)
void addCodebase(getCodebaseInput(trimmed))
.then(codebase => {
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
handleSelectProject(codebase.id);

View file

@ -19,6 +19,8 @@ export const SSE_BASE_URL = import.meta.env.DEV
? `http://${window.location.hostname}:${apiPort}`
: '';
export { getCodebaseInput } from '@/lib/codebase-input';
export interface ConversationResponse {
id: string;
platform_type: string;

View file

@ -0,0 +1,64 @@
import { describe, expect, test } from 'bun:test';
import { getCodebaseInput } from '@/lib/codebase-input';
describe('getCodebaseInput', () => {
test('treats GitHub repository inputs as urls', () => {
expect(getCodebaseInput('https://github.com/coleam00/Archon')).toEqual({
url: 'https://github.com/coleam00/Archon',
});
});
test('treats SSH git@ shorthand as urls', () => {
expect(getCodebaseInput('git@github.com:coleam00/Archon.git')).toEqual({
url: 'git@github.com:coleam00/Archon.git',
});
});
test('treats ssh:// URLs as urls', () => {
expect(getCodebaseInput('ssh://git@github.com/coleam00/Archon.git')).toEqual({
url: 'ssh://git@github.com/coleam00/Archon.git',
});
});
test('treats git:// URLs as urls', () => {
expect(getCodebaseInput('git://github.com/coleam00/Archon.git')).toEqual({
url: 'git://github.com/coleam00/Archon.git',
});
});
test('trims surrounding whitespace before classifying', () => {
expect(getCodebaseInput(' https://github.com/a/b ')).toEqual({
url: 'https://github.com/a/b',
});
});
test('treats relative local paths as paths', () => {
expect(getCodebaseInput('./repo')).toEqual({ path: './repo' });
expect(getCodebaseInput('../repo')).toEqual({ path: '../repo' });
expect(getCodebaseInput('repo')).toEqual({ path: 'repo' });
});
test('treats unix local paths as paths', () => {
expect(getCodebaseInput('/path/to/repository')).toEqual({
path: '/path/to/repository',
});
});
test('treats home-relative paths as paths', () => {
expect(getCodebaseInput('~/src/archon')).toEqual({
path: '~/src/archon',
});
});
test('treats windows local paths as paths', () => {
expect(getCodebaseInput('C:\\repo\\archon')).toEqual({
path: 'C:\\repo\\archon',
});
});
test('treats windows UNC paths as paths', () => {
expect(getCodebaseInput('\\\\server\\share\\archon')).toEqual({
path: '\\\\server\\share\\archon',
});
});
});

View file

@ -0,0 +1,10 @@
/**
* Classify input for POST /api/codebases. A `url` key signals a remote clone;
* a `path` key signals registering a local/relative path (server resolves
* tilde/relative). Inputs without an explicit remote prefix fall through to `path`.
*/
export function getCodebaseInput(value: string): { path: string } | { url: string } {
const trimmed = value.trim();
const isRemoteUrl = /^(https?:\/\/|ssh:\/\/|git@|git:\/\/)/i.test(trimmed);
return isRemoteUrl ? { url: trimmed } : { path: trimmed };
}

View file

@ -7,7 +7,7 @@ import { ConversationItem } from '@/components/conversations/ConversationItem';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { useProject } from '@/contexts/ProjectContext';
import { listConversations, listWorkflowRuns, addCodebase } from '@/lib/api';
import { listConversations, listWorkflowRuns, addCodebase, getCodebaseInput } from '@/lib/api';
import type { CodebaseResponse } from '@/lib/api';
import { cn } from '@/lib/utils';
@ -146,11 +146,7 @@ export function ChatPage(): React.ReactElement {
setAddLoading(true);
setAddError(null);
const isLocalPath =
trimmed.startsWith('/') || trimmed.startsWith('~') || /^[A-Za-z]:[/\\]/.test(trimmed);
const input = isLocalPath ? { path: trimmed } : { url: trimmed };
void addCodebase(input)
void addCodebase(getCodebaseInput(trimmed))
.then(codebase => {
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
setSelectedProjectId(codebase.id);

View file

@ -11,6 +11,7 @@ import {
listCodebases,
listProviders,
addCodebase,
getCodebaseInput,
deleteCodebase,
updateAssistantConfig,
getCodebaseEnvVars,
@ -258,7 +259,7 @@ function EnvVarsPanel({ codebaseId }: { codebaseId: string }): React.ReactElemen
function ProjectsSection(): React.ReactElement {
const queryClient = useQueryClient();
const [addPath, setAddPath] = useState('');
const [addValue, setAddValue] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [expandedEnvVars, setExpandedEnvVars] = useState<string | null>(null);
@ -268,10 +269,10 @@ function ProjectsSection(): React.ReactElement {
});
const addMutation = useMutation({
mutationFn: ({ path }: { path: string }) => addCodebase({ path }),
mutationFn: (value: string) => addCodebase(getCodebaseInput(value)),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['codebases'] });
setAddPath('');
setAddValue('');
setShowAdd(false);
},
});
@ -285,8 +286,8 @@ function ProjectsSection(): React.ReactElement {
function handleAddSubmit(e: React.FormEvent): void {
e.preventDefault();
if (addPath.trim()) {
addMutation.mutate({ path: addPath.trim() });
if (addValue.trim()) {
addMutation.mutate(addValue.trim());
}
}
@ -339,11 +340,11 @@ function ProjectsSection(): React.ReactElement {
{showAdd ? (
<form onSubmit={handleAddSubmit} className="mt-3 flex gap-2">
<Input
value={addPath}
value={addValue}
onChange={e => {
setAddPath(e.target.value);
setAddValue(e.target.value);
}}
placeholder="/path/to/repository"
placeholder="GitHub URL or local path"
className="flex-1"
/>
<Button type="submit" size="sm" disabled={addMutation.isPending}>
@ -355,7 +356,7 @@ function ProjectsSection(): React.ReactElement {
size="sm"
onClick={() => {
setShowAdd(false);
setAddPath('');
setAddValue('');
}}
>
Cancel