diff --git a/CLA.md b/CLA.md index 191145f8..a96cfd96 100644 --- a/CLA.md +++ b/CLA.md @@ -95,6 +95,7 @@ Signed-off by: — GitHub @ — YYYY-MM-DD ### Signatories - Hako (devswha) — project owner; copyright holder, not a licensor to itself. +- Signed-off by: 이종명 <114718483+lee98www@users.noreply.github.com> — GitHub @lee98www — 2026-09-04 --- diff --git a/server/index.js b/server/index.js index 4ec0c095..180bbb39 100755 --- a/server/index.js +++ b/server/index.js @@ -47,6 +47,7 @@ import gitRoutes from './routes/git.js'; import authRoutes from './routes/auth.js'; import settingsRoutes from './routes/settings.js'; import { createGjcAppFactory } from './app-factory.js'; +import { isWorkspaceRoot } from './modules/projects/index.js'; import projectModuleRoutes from './modules/projects/projects.routes.js'; import notificationRoutes from './modules/notifications/notifications.routes.js'; import userRoutes from './routes/user.js'; @@ -577,7 +578,13 @@ app.get('/api/projects/:projectId/files', authenticateToken, async (req, res) => return res.status(404).json({ error: `Project path not found: ${actualPath}` }); } - const files = await getFileTree(actualPath, 10, 0, true); + // A workspace root (~/Projects: no repo of its own, dozens of child + // repos) is where a session picks a child repo, not a tree to mention + // files from. Walking it ten levels deep stats every file in every + // repo and pins the event loop for minutes, so it lists the root's own + // entries but does not open any child repo. + const depth = (await isWorkspaceRoot(actualPath)) ? 0 : 10; + const files = await getFileTree(actualPath, depth, 0, true); res.json(files); } catch (error) { console.error('[ERROR] File tree error:', error.message); diff --git a/server/modules/projects/index.ts b/server/modules/projects/index.ts index f199888b..e10c30dd 100644 --- a/server/modules/projects/index.ts +++ b/server/modules/projects/index.ts @@ -14,8 +14,10 @@ import { generateDisplayName as displayName, getProjectsWithSessions as projectsWithSessions, } from './services/projects-with-sessions-fetch.service.js'; +import { isWorkspaceRoot as workspaceRoot } from './services/workspace-target.service.js'; export { + workspaceRoot as isWorkspaceRoot, displayName as generateDisplayName, grantAlwaysAllow as grantProjectAlwaysAllow, projectsWithSessions as getProjectsWithSessions, diff --git a/server/modules/projects/projects.routes.ts b/server/modules/projects/projects.routes.ts index 49ecfece..40dff12a 100644 --- a/server/modules/projects/projects.routes.ts +++ b/server/modules/projects/projects.routes.ts @@ -3,6 +3,7 @@ import express from 'express'; import { deleteOrArchiveProject, restoreArchivedProject } from '@/modules/projects/services/project-delete.service.js'; import { startCloneProject, type CloneProjectOperation } from '@/modules/projects/services/project-clone.service.js'; import { createProject, promoteProjectOrigin, updateProjectDisplayName } from '@/modules/projects/services/project-management.service.js'; +import { descendIntoChild, resolveWorkspaceTarget } from '@/modules/projects/services/workspace-target.service.js'; import { getProjectPermissions, listConfiguredProjectPermissions, @@ -95,6 +96,21 @@ router.post('/:projectId/promote', asyncHandler(async (request, response) => { response.json({ success: true, project: promoteProjectOrigin(projectId) }); })); +router.get('/:projectId/resolve-target', asyncHandler(async (request, response) => { + const projectId = routeProjectId(request.params.projectId, true); + const result = await resolveWorkspaceTarget(projectId, queryText(request.query.text)); + response.json(createApiSuccessResponse(result)); +})); + +router.post('/:projectId/descend', asyncHandler(async (request, response) => { + const projectId = routeProjectId(request.params.projectId, true); + const body: { path?: unknown } = request.body ?? {}; + const childPath = typeof body.path === 'string' ? body.path : ''; + if (!childPath) throw new AppError('path is required', { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 }); + const { created, project } = await descendIntoChild(projectId, childPath); + response.status(created ? 201 : 200).json(createApiSuccessResponse(project)); +})); + router.get('/:projectId/sessions', asyncHandler(async (request, response) => { const sessions = await getProjectSessionsPage(routeProjectId(request.params.projectId), { limit: nonNegativeQueryNumber(request.query.limit, 'limit', 20), diff --git a/server/modules/projects/services/project-management.service.ts b/server/modules/projects/services/project-management.service.ts index dcd8b5f1..a1cb829b 100644 --- a/server/modules/projects/services/project-management.service.ts +++ b/server/modules/projects/services/project-management.service.ts @@ -41,7 +41,7 @@ const projectStore: CreateProjectDependencies = { validatePath: validateWorkspacePath, // shared workspace gate from utils }; -function projectApiView(project: ProjectRepositoryRow): ProjectApiView { +export function projectApiView(project: ProjectRepositoryRow): ProjectApiView { const fullPath = project.project_path; const view: ProjectApiView = { projectId: project.project_id, diff --git a/server/modules/projects/services/workspace-target-scoring.ts b/server/modules/projects/services/workspace-target-scoring.ts new file mode 100644 index 00000000..eb544dc6 --- /dev/null +++ b/server/modules/projects/services/workspace-target-scoring.ts @@ -0,0 +1,73 @@ +export type WorkspaceCandidateReason = 'mention' | 'partial' | 'recent'; + +export type WorkspaceCandidate = { + path: string; + name: string; + score: number; + reason: WorkspaceCandidateReason; +}; + +export type WorkspaceScoringChild = { + path: string; + name: string; + packageName: string | null; + mtimeMs: number; +}; + +const TOKEN_CHARACTER = /[a-z0-9._-]/; + +function candidateNames(child: WorkspaceScoringChild): string[] { + const names = [child.name.toLowerCase(), child.packageName?.toLowerCase() ?? null].filter( + (name): name is string => Boolean(name), + ); + return Array.from(new Set(names)); +} + +// `name` occurs in the text with nothing token-like on either side, so a repo named +// `gajae-code` is not "mentioned" by a message about `gajae-code-app`. This is how a +// name made of characters outside the token class (a Korean directory) gets found. +function mentionedWhole(name: string, lowerText: string): boolean { + let from = 0; + for (;;) { + const at = lowerText.indexOf(name, from); + if (at === -1) return false; + const before = at === 0 ? '' : lowerText[at - 1]; + const after = lowerText[at + name.length] ?? ''; + if (!TOKEN_CHARACTER.test(before) && !TOKEN_CHARACTER.test(after)) return true; + from = at + 1; + } +} + +function scoreAgainstName(name: string, lowerText: string, tokens: string[]): { score: number; reason: WorkspaceCandidateReason } { + if (tokens.includes(name)) return { score: 100, reason: 'mention' }; + // Names the ASCII tokenizer would mangle (Korean directories or symbols like + // c++) can only be found by whole mention; boundaries keep short ASCII names + // such as "go" from false-positiving. + if ((name.length >= 4 || (name.length >= 2 && /[^a-z0-9._-]/.test(name))) && mentionedWhole(name, lowerText)) return { score: 80, reason: 'mention' }; + if (tokens.some((token) => token.length >= 3 && name.startsWith(token))) return { score: 40, reason: 'partial' }; + return { score: 0, reason: 'recent' }; +} + +/** + * Ranks every child repository of a workspace against free-form task text. Each child + * scores by its best match across the directory name and the (scope-stripped) + * package.json name; ties, and all children when the text is empty, fall back to + * directory recency. The whole list comes back so a picker can show every repo, with + * the likely target first. + */ +export function scoreWorkspaceCandidates(text: string, children: WorkspaceScoringChild[]): WorkspaceCandidate[] { + const lowerText = text.trim().toLowerCase(); + const tokens = lowerText.split(/[^a-z0-9._-]+/).filter(Boolean); + + const scored = children.map((child) => { + let best = { score: 0, reason: 'recent' as WorkspaceCandidateReason }; + for (const name of candidateNames(child)) { + const candidate = scoreAgainstName(name, lowerText, tokens); + if (candidate.score > best.score) best = candidate; + } + return { path: child.path, name: child.name, score: best.score, reason: best.reason, mtimeMs: child.mtimeMs }; + }); + + scored.sort((a, b) => b.score - a.score || b.mtimeMs - a.mtimeMs); + return scored.map(({ path, name, score, reason }) => ({ path, name, score, reason })); +} diff --git a/server/modules/projects/services/workspace-target.service.ts b/server/modules/projects/services/workspace-target.service.ts new file mode 100644 index 00000000..908bf052 --- /dev/null +++ b/server/modules/projects/services/workspace-target.service.ts @@ -0,0 +1,150 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { projectsDb } from '@/modules/database/index.js'; +import { projectApiView, type ProjectApiView } from '@/modules/projects/services/project-management.service.js'; +import { scoreWorkspaceCandidates, type WorkspaceCandidate, type WorkspaceScoringChild } from '@/modules/projects/services/workspace-target-scoring.js'; +import type { ProjectRepositoryRow } from '@/shared/types.js'; +import { AppError, normalizeProjectPath } from '@/shared/utils.js'; + +export type { WorkspaceCandidate }; + +export type ResolveWorkspaceTargetResult = { + isWorkspace: boolean; + candidates: WorkspaceCandidate[]; +}; + +export type DescendIntoChildResult = { + created: boolean; + project: ProjectApiView; +}; + +function unknownProject(projectId: string): AppError { + return new AppError(`Unknown projectId: ${projectId}`, { code: 'PROJECT_NOT_FOUND', statusCode: 404 }); +} + +async function hasGitEntry(directoryPath: string): Promise { + try { + await fs.stat(path.join(directoryPath, '.git')); + return true; + } catch (error) { + const failure = error as NodeJS.ErrnoException; + if (failure.code === 'ENOENT') return false; + throw failure; + } +} + +function isCandidateDirName(name: string): boolean { + return name !== 'node_modules' && !name.startsWith('.'); +} + +async function readPackageName(directoryPath: string): Promise { + try { + const manifestPath = path.join(directoryPath, 'package.json'); + const stats = await fs.lstat(manifestPath); + if (!stats.isFile() || stats.size > 64 * 1024) return null; + const raw = await fs.readFile(manifestPath, 'utf8'); + const parsed = JSON.parse(raw) as { name?: unknown }; + const name = typeof parsed.name === 'string' ? parsed.name.trim() : ''; + if (!name) return null; + // Scoped packages (`@scope/name`) contribute only the unscoped segment. + return name.startsWith('@') ? name.split('/').slice(1).join('/') || null : name; + } catch { + return null; + } +} + +/** + * Lists the immediate child directories of `dir` that are themselves git repositories + * (contain a `.git` entry). Hidden directories and `node_modules` are never candidates. + */ +export async function listChildRepos(dir: string, options: { withPackageNames?: boolean } = {}): Promise { + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch (error) { + const failure = error as NodeJS.ErrnoException; + if (failure.code === 'ENOENT' || failure.code === 'ENOTDIR') return []; + throw failure; + } + + const children: WorkspaceScoringChild[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || !isCandidateDirName(entry.name)) continue; + const childPath = path.join(dir, entry.name); + if (!(await hasGitEntry(childPath))) continue; + let stats: import('node:fs').Stats; + try { + stats = await fs.stat(childPath); + } catch (error) { + const failure = error as NodeJS.ErrnoException; + if (failure.code === 'ENOENT') continue; + throw failure; + } + children.push({ + path: childPath, + name: entry.name, + packageName: options.withPackageNames === false ? null : await readPackageName(childPath), + mtimeMs: stats.mtimeMs, + }); + } + return children; +} + +async function workspaceChildren(projectPath: string, withPackageNames: boolean): Promise { + if (await hasGitEntry(projectPath)) return null; + const children = await listChildRepos(projectPath, { withPackageNames }); + return children.length ? children : null; +} + +/** + * A workspace root is a directory that is not itself a git work tree, but that + * contains at least one immediate child directory that is one. + */ +export async function isWorkspaceRoot(dir: string): Promise { + return (await workspaceChildren(dir, false)) !== null; +} + +function projectRowOrThrow(projectId: string): ProjectRepositoryRow { + const project = projectsDb.getProjectById(projectId); + if (!project) throw unknownProject(projectId); + return project; +} + +export async function resolveWorkspaceTarget(projectId: string, text: string): Promise { + const project = projectRowOrThrow(projectId); + const children = await workspaceChildren(project.project_path, true); + if (!children) { + return { isWorkspace: false, candidates: [] }; + } + return { isWorkspace: true, candidates: scoreWorkspaceCandidates(text, children) }; +} + +function notWorkspaceChild(message: string): AppError { + return new AppError(message, { code: 'NOT_WORKSPACE_CHILD', statusCode: 400 }); +} + +export async function descendIntoChild(projectId: string, childPath: string): Promise { + const project = projectRowOrThrow(projectId); + const children = await workspaceChildren(project.project_path, false); + if (!children) { + throw notWorkspaceChild('Project is not a workspace root'); + } + const resolvedChildPath = normalizeProjectPath(path.resolve(childPath)); + const matchedChild = children.find((child) => normalizeProjectPath(child.path) === resolvedChildPath); + if (!matchedChild) { + throw notWorkspaceChild('path is not an immediate child repository of the workspace project'); + } + + // The user chose this repo to work in, so it becomes a sidebar project the + // same way "Add a project" does: created or un-archived as 'explicit', and a + // row the session indexer had only discovered ('auto'/'legacy') is promoted. + const { outcome, project: child } = projectsDb.createProjectPath(matchedChild.path); + if (!child) { + throw new AppError('Failed to register project for workspace child', { code: 'PROJECT_CREATE_FAILED', statusCode: 500 }); + } + const row = outcome === 'active_conflict' && child.origin !== 'explicit' + ? projectsDb.promoteProjectOriginById(child.project_id) ?? child + : child; + return { created: outcome === 'created', project: projectApiView(row) }; +} diff --git a/server/modules/projects/tests/workspace-target.test.ts b/server/modules/projects/tests/workspace-target.test.ts new file mode 100644 index 00000000..c0e9f4e8 --- /dev/null +++ b/server/modules/projects/tests/workspace-target.test.ts @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { closeConnection, initializeDatabase, projectsDb } from '@/modules/database/index.js'; +import { descendIntoChild, isWorkspaceRoot, listChildRepos, resolveWorkspaceTarget } from '@/modules/projects/services/workspace-target.service.js'; +import { scoreWorkspaceCandidates } from '@/modules/projects/services/workspace-target-scoring.js'; +import { AppError } from '@/shared/utils.js'; + +async function withDatabase(action: () => void | Promise): Promise { + const previous = process.env.DATABASE_PATH; + const directory = await mkdtemp(path.join(tmpdir(), 'workspace-target-db-')); + closeConnection(); + process.env.DATABASE_PATH = path.join(directory, 'app.sqlite'); + try { + await initializeDatabase(); + await action(); + } finally { + closeConnection(); + if (previous === undefined) delete process.env.DATABASE_PATH; + else process.env.DATABASE_PATH = previous; + await rm(directory, { recursive: true, force: true }); + } +} + +async function makeGitRepo(dir: string, packageName?: string | { invalid: true }): Promise { + await mkdir(path.join(dir, '.git'), { recursive: true }); + if (packageName !== undefined) { + const content = packageName && typeof packageName === 'object' + ? '{ this is not valid json' + : JSON.stringify({ name: packageName }); + await writeFile(path.join(dir, 'package.json'), content); + } +} + +const code = (error: unknown) => (error instanceof AppError ? error.code : undefined); +const statusCode = (error: unknown) => (error instanceof AppError ? error.statusCode : undefined); + +// ---- scoring (pure function) ---- + +test('scoring: exact token match beats substring, prefix, and recency', () => { + const children = [ + { path: '/w/dashboard', name: 'dashboard', packageName: null, mtimeMs: 1000 }, + { path: '/w/dash-utils', name: 'dash-utils', packageName: null, mtimeMs: 2000 }, + { path: '/w/other', name: 'other', packageName: null, mtimeMs: 3000 }, + ]; + const results = scoreWorkspaceCandidates('fix the dashboard bug', children); + assert.deepEqual(results[0], { path: '/w/dashboard', name: 'dashboard', score: 100, reason: 'mention' }); +}); + +test('scoring: a name outside the token class is found as a whole mention', () => { + const children = [ + { path: '/w/웹툰분석', name: '웹툰분석', packageName: null, mtimeMs: 1000 }, + { path: '/w/api', name: 'api', packageName: null, mtimeMs: 2000 }, // length 3: too short for the whole-mention rule + ]; + const results = scoreWorkspaceCandidates('웹툰분석 api 고쳐줘', children); + const byName = new Map(results.map((entry) => [entry.name, entry])); + assert.equal(byName.get('웹툰분석')?.score, 80); + assert.equal(byName.get('웹툰분석')?.reason, 'mention'); + // `api` still scores as an exact token, the whole-mention rule is only for longer names. + assert.equal(byName.get('api')?.score, 100); +}); + +test('scoring: short Unicode and symbol names whole-mention, short ASCII names do not', () => { + const children = [ + { path: '/w/前端', name: '前端', packageName: null, mtimeMs: 1 }, + { path: '/w/c++', name: 'c++', packageName: null, mtimeMs: 2 }, + { path: '/w/go', name: 'go', packageName: null, mtimeMs: 3 }, + ]; + const byName = new Map(scoreWorkspaceCandidates('前端 c++ going', children).map((entry) => [entry.name, entry])); + assert.equal(byName.get('前端')?.score, 80); + assert.equal(byName.get('c++')?.score, 80); + assert.equal(byName.get('go')?.score, 0); + assert.equal(scoreWorkspaceCandidates('go', children).find((entry) => entry.name === 'go')?.score, 100); +}); + +test('scoring: a repo whose name is a prefix of the mentioned one is not a mention', () => { + const children = [ + { path: '/w/gajae-code', name: 'gajae-code', packageName: null, mtimeMs: 3000 }, + { path: '/w/gajae-code-app', name: 'gajae-code-app', packageName: null, mtimeMs: 1000 }, + { path: '/w/gajae-code-hotfix', name: 'gajae-code-hotfix', packageName: 'gajae-code', mtimeMs: 2000 }, + ]; + const results = scoreWorkspaceCandidates('gajae-code-app에 칩 넣어줘', children); + // Neither the shorter directory name nor the clone's package.json name is a + // mention; both fall back to recency behind the real target. + assert.deepEqual(results.map((entry) => [entry.name, entry.score]), [ + ['gajae-code-app', 100], + ['gajae-code', 0], + ['gajae-code-hotfix', 0], + ]); +}); + +test('scoring: prefix match requires token length >= 3', () => { + const children = [{ path: '/w/dashboard', name: 'dashboard', packageName: null, mtimeMs: 1000 }]; + assert.equal(scoreWorkspaceCandidates('dash it', children)[0].score, 40); + assert.deepEqual(scoreWorkspaceCandidates('dash it', children)[0].reason, 'partial'); + assert.equal(scoreWorkspaceCandidates('da it', children)[0].score, 0); +}); + +test('scoring: package.json name contributes as a candidate name alongside the directory name', () => { + // Scope-stripping happens where the caller reads package.json; the pure scoring + // function receives the already-stripped name. + const children = [{ path: '/w/dir-name', name: 'dir-name', packageName: 'real-name', mtimeMs: 1000 }]; + assert.equal(scoreWorkspaceCandidates('fix real-name issue', children)[0].score, 100); +}); + +test('scoring: empty text returns every child, reason recent, sorted by mtime desc', () => { + const children = [ + { path: '/w/a', name: 'a', packageName: null, mtimeMs: 100 }, + { path: '/w/b', name: 'b', packageName: null, mtimeMs: 300 }, + { path: '/w/c', name: 'c', packageName: null, mtimeMs: 200 }, + ]; + assert.deepEqual(scoreWorkspaceCandidates(' ', children), [ + { path: '/w/b', name: 'b', score: 0, reason: 'recent' }, + { path: '/w/c', name: 'c', score: 0, reason: 'recent' }, + { path: '/w/a', name: 'a', score: 0, reason: 'recent' }, + ]); +}); + +test('scoring: every child comes back, mentions first, the rest by recency', () => { + const children = Array.from({ length: 10 }, (_, index) => ({ + path: `/w/repo${index}`, + name: `repo${index}`, + packageName: null, + mtimeMs: index, + })); + const results = scoreWorkspaceCandidates('mention repo3 and repo8', children); + assert.equal(results.length, 10); + // Both mentions score 100; the more recently touched one (repo8) leads. + assert.deepEqual(results.slice(0, 2).map((entry) => entry.name), ['repo8', 'repo3']); + // Everything else is unscored and ordered newest first so a picker stays useful. + assert.ok(results.slice(2).every((entry) => entry.score === 0)); + assert.deepEqual(results.slice(2, 5).map((entry) => entry.name), ['repo9', 'repo7', 'repo6']); +}); + +// ---- service (fixture directory + db) ---- + +async function withWorkspaceFixture(action: (fixture: { + workspaceDir: string; + repoA: string; + repoB: string; +}) => Promise): Promise { + const workspaceDir = await mkdtemp(path.join(tmpdir(), 'gajae-workspace-')); + try { + const repoA = path.join(workspaceDir, 'alpha'); + const repoB = path.join(workspaceDir, 'beta'); + await makeGitRepo(repoA, 'alpha-pkg'); + await makeGitRepo(repoB); + await mkdir(path.join(workspaceDir, '.hidden-dir'), { recursive: true }); + await mkdir(path.join(workspaceDir, 'node_modules'), { recursive: true }); + await mkdir(path.join(workspaceDir, 'plain-dir'), { recursive: true }); // no .git + await action({ workspaceDir, repoA, repoB }); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } +} + +test('isWorkspaceRoot / listChildRepos: detects workspace, skips hidden/node_modules/non-git dirs', async () => { + await withWorkspaceFixture(async ({ workspaceDir, repoA, repoB }) => { + assert.equal(await isWorkspaceRoot(workspaceDir), true); + const children = await listChildRepos(workspaceDir); + const paths = children.map((child) => child.path).sort(); + assert.deepEqual(paths, [repoA, repoB].sort()); + + // A directory that IS a git worktree itself is not a workspace root. + assert.equal(await isWorkspaceRoot(repoA), false); + + // A directory with no git-containing children is not a workspace root. + const plainRoot = await mkdtemp(path.join(tmpdir(), 'gajae-plain-')); + try { + await mkdir(path.join(plainRoot, 'child'), { recursive: true }); + assert.equal(await isWorkspaceRoot(plainRoot), false); + } finally { + await rm(plainRoot, { recursive: true, force: true }); + } + }); +}); + +test('listChildRepos: ignores unsafe, oversized, and invalid manifests', async () => { + const workspace = await mkdtemp(path.join(tmpdir(), 'workspace-manifests-')); + const outside = path.join(workspace, 'outside.json'); + try { + await writeFile(outside, JSON.stringify({ name: 'tantalizing' })); + for (const name of ['linked', 'large', 'invalid']) await makeGitRepo(path.join(workspace, name)); + await symlink(outside, path.join(workspace, 'linked', 'package.json')); + await writeFile(path.join(workspace, 'large', 'package.json'), JSON.stringify({ name: 'x'.repeat(64 * 1024) })); + await writeFile(path.join(workspace, 'invalid', 'package.json'), '{ nope'); + const children = await listChildRepos(workspace); + assert.ok(children.every((child) => child.packageName === null)); + assert.equal(scoreWorkspaceCandidates('linked', children).find((child) => child.name === 'linked')?.score, 100); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test('resolveWorkspaceTarget: 404s for unknown project, reports non-workspace projects, scores workspace children', async () => { + await withDatabase(async () => { + await assert.rejects( + () => resolveWorkspaceTarget('unknown-project', ''), + (error: unknown) => code(error) === 'PROJECT_NOT_FOUND' && statusCode(error) === 404, + ); + + await withWorkspaceFixture(async ({ workspaceDir, repoA }) => { + const workspaceProject = projectsDb.createProjectPath(workspaceDir).project; + assert.ok(workspaceProject); + const result = await resolveWorkspaceTarget(workspaceProject.project_id, 'work on alpha-pkg please'); + assert.equal(result.isWorkspace, true); + assert.equal(result.candidates[0].path, repoA); + assert.equal(result.candidates[0].score, 100); + + const nonWorkspaceProject = projectsDb.createProjectPath(repoA).project; + assert.ok(nonWorkspaceProject); + const nonWorkspaceResult = await resolveWorkspaceTarget(nonWorkspaceProject.project_id, 'anything'); + assert.deepEqual(nonWorkspaceResult, { isWorkspace: false, candidates: [] }); + }); + }); +}); + +test('descendIntoChild: creates an explicit sidebar project on first call, reuses on second, un-archives if archived', async () => { + await withDatabase(async () => { + await withWorkspaceFixture(async ({ workspaceDir, repoA }) => { + const workspaceProject = projectsDb.createProjectPath(workspaceDir).project; + assert.ok(workspaceProject); + + const first = await descendIntoChild(workspaceProject.project_id, repoA); + assert.equal(first.created, true); + assert.equal(first.project.fullPath, repoA); + // The sidebar lists explicit projects only; a repo the user descended + // into has to show up there with its session. + assert.equal(first.project.origin, 'explicit'); + + const second = await descendIntoChild(workspaceProject.project_id, repoA); + assert.equal(second.created, false); + assert.equal(second.project.projectId, first.project.projectId); + + projectsDb.updateProjectIsArchivedById(first.project.projectId, true); + const archivedRow = projectsDb.getProjectById(first.project.projectId); + assert.equal(archivedRow?.isArchived, 1); + + const restored = await descendIntoChild(workspaceProject.project_id, repoA); + assert.equal(restored.created, false); + assert.equal(restored.project.isArchived, false); + }); + }); +}); + +test('descendIntoChild: promotes a row the session indexer only discovered', async () => { + await withDatabase(async () => { + await withWorkspaceFixture(async ({ workspaceDir, repoA }) => { + const workspaceProject = projectsDb.createProjectPath(workspaceDir).project; + assert.ok(workspaceProject); + projectsDb.ensureProjectPathForSession(repoA); + assert.equal(projectsDb.getProjectPath(repoA)?.origin, 'auto'); + + const result = await descendIntoChild(workspaceProject.project_id, repoA); + assert.equal(result.created, false); + assert.equal(result.project.origin, 'explicit'); + assert.equal(projectsDb.getProjectPath(repoA)?.origin, 'explicit'); + }); + }); +}); + +test('descendIntoChild: rejects non-child paths, non-git children, and traversal', async () => { + await withDatabase(async () => { + await withWorkspaceFixture(async ({ workspaceDir, repoA }) => { + const workspaceProject = projectsDb.createProjectPath(workspaceDir).project; + assert.ok(workspaceProject); + + const outsideDir = await mkdtemp(path.join(tmpdir(), 'gajae-outside-')); + try { + await assert.rejects( + () => descendIntoChild(workspaceProject.project_id, outsideDir), + (error: unknown) => code(error) === 'NOT_WORKSPACE_CHILD' && statusCode(error) === 400, + ); + + await assert.rejects( + () => descendIntoChild(workspaceProject.project_id, path.join(workspaceDir, 'plain-dir')), + (error: unknown) => code(error) === 'NOT_WORKSPACE_CHILD' && statusCode(error) === 400, + ); + + await assert.rejects( + () => descendIntoChild(workspaceProject.project_id, path.join(repoA, '..', '..')), + (error: unknown) => code(error) === 'NOT_WORKSPACE_CHILD' && statusCode(error) === 400, + ); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + + const nonWorkspaceProject = projectsDb.createProjectPath(repoA).project; + assert.ok(nonWorkspaceProject); + await assert.rejects( + () => descendIntoChild(nonWorkspaceProject.project_id, repoA), + (error: unknown) => code(error) === 'NOT_WORKSPACE_CHILD' && statusCode(error) === 400, + ); + }); + }); +}); diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index ea7af772..92a06c47 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -76,7 +76,7 @@ async function sessionFrame(provider: LLMProvider, providerSessionId: string): P sessionId: row.session_id, provider: row.provider, session: { id: row.session_id, summary: row.custom_name || '', messageCount: 0, lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString() }, - project: project ? { projectId: project.project_id, path: project.project_path, fullPath: project.project_path, displayName, isStarred: Boolean(project.isStarred) } : null, + project: project ? { projectId: project.project_id, path: project.project_path, fullPath: project.project_path, displayName, isStarred: Boolean(project.isStarred), origin: project.origin } : null, timestamp: new Date().toISOString(), }); } diff --git a/server/modules/websocket/services/chat-run-registry.service.ts b/server/modules/websocket/services/chat-run-registry.service.ts index 40d875f8..09230076 100644 --- a/server/modules/websocket/services/chat-run-registry.service.ts +++ b/server/modules/websocket/services/chat-run-registry.service.ts @@ -104,6 +104,7 @@ async function broadcastSessionUpsert(sessionId: string): Promise { fullPath: project.project_path, displayName, isStarred: Boolean(project.isStarred), + origin: project.origin, }, timestamp: new Date().toISOString(), }); diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index 65fbede9..06d879c0 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -18,6 +18,7 @@ import { decideQueueFlush } from '../utils/queueFlush'; import { useFileMentions } from './useFileMentions'; import { type SlashCommand, useSlashCommands } from './useSlashCommands'; +import { useWorkspaceTarget, type WorkspaceCandidate } from './useWorkspaceTarget'; interface UseChatComposerStateArgs { selectedProject: Project | null; selectedSession: ProjectSession | null; currentSessionId: string | null; gjcModel: string; reasoningEffort?: string; isLoading: boolean; canAbortSession: boolean; tokenBudget: Record | null; sendMessage: (message: unknown) => void; sendByCtrlEnter?: boolean; onSessionProcessing?: MarkSessionProcessing; onSessionEstablished?: (sessionId: string, context: SessionEstablishedContext) => void; onInputFocusChange?: (focused: boolean) => void; onCommandGateChange?: (gate: PendingCommandGate | null) => void; onFileOpen?: (filePath: string, diffInfo?: CodeEditorDiffInfo | null) => void; onShowSettings?: () => void; onLogin?: (providerId?: string) => void; scrollToBottom: () => void; addMessage: (msg: ChatMessage) => void; setIsUserScrolledUp: (isScrolledUp: boolean) => void; setPendingPermissionRequests: Dispatch>; } interface MentionableFile { name: string; path: string; } @@ -111,7 +112,13 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { // by the server when the run starts, so nothing the browser sends can widen it. const optionsFor = useCallback((text: string): QueuedSendOptions => ({ model: gjcModel, effort: reasoningEffort, sessionSummary: sessionLabel(selectedSession, text) }), [gjcModel, reasoningEffort, selectedSession]); const upload = useCallback(async (files: File[]) => { if (!files.length) return []; const body = new FormData(); files.forEach((file) => body.append('images', file)); const response = await authenticatedFetch('/api/assets/images', { method: 'POST', headers: {}, body }); if (!response.ok) throw new Error('Failed to upload images'); return (await response.json()).images as unknown[]; }, []); - const allocate = useCallback(async (summary: string | null) => { let id = selectedSession ? (selectedSession.__provider === 'gjc' ? selectedSession.id : null) : currentSessionId; if (id) return id; const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: selectedProject?.fullPath || selectedProject?.path || '' }) }); if (!response.ok) throw new Error(`Failed to create session (${response.status})`); id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); onSessionEstablished?.(id, { provider: 'gjc', project: selectedProject!, summary }); return id; }, [currentSessionId, onSessionEstablished, selectedProject, selectedSession]); + const workspaceTarget = useWorkspaceTarget({ selectedProject, selectedSession, currentSessionId, input }); + const { resolveForSend } = workspaceTarget; + // A workspace root (e.g. `~/Projects`) never hosts a session itself: the + // resolved child repo becomes the session's project, and it is what the + // caller (ChatInterface's `establishSession`) registers into the sidebar. + const descend = useCallback(async (target: WorkspaceCandidate): Promise => { const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(selectedProject!.projectId)}/descend`, { method: 'POST', body: JSON.stringify({ path: target.path }) }); if (!response.ok) { const body = await response.json().catch(() => ({})) as { error?: string | { message?: string } }; const error = body.error; throw new Error(typeof error === 'string' ? error : error?.message ?? `Failed to switch to ${target.name} (${response.status})`); } return (await response.json())?.data as Project; }, [selectedProject]); + const allocate = useCallback(async (summary: string | null, text: string) => { let id = selectedSession ? (selectedSession.__provider === 'gjc' ? selectedSession.id : null) : currentSessionId; if (id) return id; const target = await resolveForSend(text); const project = target ? await descend(target) : selectedProject; const response = await authenticatedFetch('/api/providers/sessions', { method: 'POST', body: JSON.stringify({ provider: 'gjc', projectPath: project?.fullPath || project?.path || '' }) }); if (!response.ok) throw new Error(`Failed to create session (${response.status})`); id = (await response.json())?.data?.sessionId || null; if (!id) throw new Error('no session id returned.'); onSessionEstablished?.(id, { provider: 'gjc', project: project!, summary }); return id; }, [currentSessionId, descend, onSessionEstablished, resolveForSend, selectedProject, selectedSession]); const handleSubmit = useCallback(async (event: FormEvent | MouseEvent | TouchEvent | KeyboardEvent) => { event.preventDefault(); const text = inputRef.current; if (!text.trim() || !selectedProject) return; @@ -120,7 +127,7 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const candidate = text.trimEnd(); const help = candidate.trim().toLowerCase() === 'help'; if (candidate.startsWith('/') || help) { const gap = candidate.indexOf(' '); const name = help ? '/help' : gap > 0 ? candidate.slice(0, gap) : candidate; const commandArgs = gap > 0 ? candidate.slice(gap).trim() : ''; const app = findAppUiCommand(resolveCommandAlias(name)); if (app && (app.interceptWithArgs !== false || !commandArgs)) { clearComposer(); applyAppCommand(app); return; } const notice = getLocalCommandNotice(name, commandArgs); if (notice) { clearComposer(); addMessage({ type: 'assistant', content: notice, timestamp: Date.now() }); return; } if (!bypassGate.current) { const gate = gateForCommand(resolveCommandAlias(name), commandArgs); if (gate) { clearComposer(); announceGate({ ...gate, text: candidate }); return; } } bypassGate.current = false; const registered = slashCommands.find((item) => item.name === name); if (registered && !isAppUiCommand(registered) && registered.type !== 'skill' && registered.type !== 'provider') { void executeCommand(registered, help ? '/help' : candidate); clearComposer(); return; } } let images: unknown[]; try { images = await upload(attachedImages); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Image upload failed:', error); addMessage({ type: 'error', content: `Failed to upload images: ${message}`, timestamp: new Date() }); return; } - const summary = sessionLabel(selectedSession, text); let id: string; try { id = await allocate(summary); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Session creation failed:', error); addMessage({ type: 'error', content: `Failed to start a new session: ${message}`, timestamp: new Date() }); return; } + const summary = sessionLabel(selectedSession, text); let id: string; try { id = await allocate(summary, text); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error('Session creation failed:', error); addMessage({ type: 'error', content: `Failed to start a new session: ${message}`, timestamp: new Date() }); return; } addMessage({ type: 'user', content: text, images: images as any, timestamp: new Date() }); onSessionProcessing?.(id, { statusText: null, canInterrupt: true }); setIsUserScrolledUp(false); setTimeout(scrollToBottom, 100); sendMessage({ type: 'chat.send', sessionId: id, content: text, options: { ...optionsFor(text), images } }); clearComposer(); eraseDraft(id); }, [addMessage, allocate, announceGate, applyAppCommand, attachedImages, clearComposer, conversation, eraseDraft, executeCommand, isLoading, login, onSessionProcessing, optionsFor, resetCommandMenuState, scrollToBottom, selectedProject, selectedSession, sendMessage, setIsUserScrolledUp, slashCommands, upload]); useEffect(() => { submitRef.current = handleSubmit; }, [handleSubmit]); @@ -164,5 +171,5 @@ export function useChatComposerState(args: UseChatComposerStateArgs) { const handleAbortSession = useCallback(() => { if (!canAbortSession) return; const id = selectedSession?.id || currentSessionId; if (!id) { console.warn('Abort requested but no session ID is available.'); return; } sendMessage({ type: 'chat.abort', sessionId: id }); }, [canAbortSession, currentSessionId, selectedSession?.id, sendMessage]); const handlePermissionDecision = useCallback((requestIds: string | string[], decision: PermissionDecision) => { const ids = (Array.isArray(requestIds) ? requestIds : [requestIds]).filter(Boolean); ids.forEach((requestId) => sendMessage(permissionResponseMessage(requestId, decision))); if (ids.length) setPendingPermissionRequests((requests) => requests.filter((request) => !ids.includes(request.requestId))); }, [sendMessage, setPendingPermissionRequests]); const handleInputFocusChange = useCallback((focused: boolean) => { setFocused(focused); onInputFocusChange?.(focused); }, [onInputFocusChange]); - return { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal }; + return { input, setInput, textareaRef, inputHighlightRef, isTextareaExpanded, slashCommandsCount, skillCommands: slashCommands.filter((command) => command.type === 'skill'), filteredCommands, frequentCommands, commandQuery, showCommandMenu, selectedCommandIndex, resetCommandMenuState, handleCommandSelect, handleToggleCommandMenu, showFileDropdown, filteredFiles: filteredFiles as MentionableFile[], selectedFileIndex, renderInputWithMentions, selectFile, attachedImages, setAttachedImages, uploadingImages, imageErrors, getRootProps, getInputProps, isDragActive, openImagePicker: open, handleSubmit, handleSteer, modelPickerTrigger, queuedDrafts, editQueuedDraft, deleteQueuedDraft, moveQueuedDraft, resolveSteerResult, pendingCommandGate, confirmCommandGate, cancelCommandGate, handleVoiceTranscript, insertAtEnd, handleInputChange, handleKeyDown, handlePaste, handleTextareaClick: (event: MouseEvent) => setCursorPosition(event.currentTarget.selectionStart), handleTextareaInput, syncInputOverlayScroll, handleClearInput, handleAbortSession, handlePermissionDecision, handleInputFocusChange, isInputFocused, commandModalPayload, closeCommandModal: () => setModal(null), showCostModal, isWorkspace: workspaceTarget.isWorkspace, workspaceCandidates: workspaceTarget.candidates, workspaceTargetValue: workspaceTarget.target, pickWorkspaceTarget: workspaceTarget.pickTarget }; } diff --git a/src/components/chat/hooks/useWorkspaceTarget.dom.bun.test.tsx b/src/components/chat/hooks/useWorkspaceTarget.dom.bun.test.tsx new file mode 100644 index 00000000..651086ea --- /dev/null +++ b/src/components/chat/hooks/useWorkspaceTarget.dom.bun.test.tsx @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; + +import type { Project, ProjectSession } from '../../../types/app'; + +import { useWorkspaceTarget } from './useWorkspaceTarget'; + +/* + * The hook drives two networked probes an effect apart (the empty-text mount + * probe, then a 300ms-debounced re-query as the user types) and a pin that + * survives some state changes but not others. None of that is reachable from + * a static render, so this drives the real hook against a mocked fetch. + */ + +const project: Project = { + projectId: 'proj-workspace', + displayName: 'Projects', + fullPath: '/Users/dev/Projects', + origin: 'explicit', +}; + +const session = (id: string): ProjectSession => ({ id } as ProjectSession); + +const candidateA = { path: '/Users/dev/Projects/repo-a', name: 'repo-a', score: 100, reason: 'mention' as const }; +const candidateB = { path: '/Users/dev/Projects/repo-b', name: 'repo-b', score: 40, reason: 'partial' as const }; + +type FetchCall = { url: string }; + +function installFetch(responder: (call: FetchCall) => { isWorkspace: boolean; candidates: unknown[] }) { + const calls: FetchCall[] = []; + const original = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + calls.push({ url }); + const data = responder({ url }); + return new Response(JSON.stringify({ success: true, data }), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof fetch; + return { calls, restore: () => { globalThis.fetch = original; } }; +} + +const composer = (overrides: Partial[0]> = {}) => + renderHook( + (props: Partial[0]>) => useWorkspaceTarget({ + selectedProject: project, + selectedSession: null, + currentSessionId: null, + input: '', + ...props, + } as never), + { initialProps: overrides }, + ); + +afterEach(cleanup); + +test('a non-workspace project resolves once on mount and stays inert', async () => { + const fetch = installFetch(() => ({ isWorkspace: false, candidates: [] })); + try { + const view = composer(); + await waitFor(() => assert.equal(fetch.calls.length, 1)); + assert.match(fetch.calls[0].url, /\/api\/projects\/proj-workspace\/resolve-target\?text=$/); + assert.equal(view.result.current.isWorkspace, false); + assert.equal(view.result.current.target, null); + } finally { + fetch.restore(); + } +}); + +test('a workspace project auto-targets the top candidate once its score clears the threshold', async () => { + const fetch = installFetch(({ url }) => { + const text = new URL(url, 'http://local').searchParams.get('text') ?? ''; + if (!text) return { isWorkspace: true, candidates: [] }; + return { isWorkspace: true, candidates: [candidateA, candidateB] }; + }); + try { + const view = composer(); + await waitFor(() => assert.equal(view.result.current.isWorkspace, true)); + + view.rerender({ input: 'fix repo-a bug' }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)); }); + await waitFor(() => assert.equal(view.result.current.target?.name, 'repo-a')); + assert.equal(view.result.current.pinned, false); + } finally { + fetch.restore(); + } +}); + +test('a candidate below the auto-target threshold is offered but not auto-selected', async () => { + const fetch = installFetch(({ url }) => { + const text = new URL(url, 'http://local').searchParams.get('text') ?? ''; + if (!text) return { isWorkspace: true, candidates: [] }; + return { isWorkspace: true, candidates: [candidateB] }; + }); + try { + const view = composer(); + await waitFor(() => assert.equal(view.result.current.isWorkspace, true)); + + view.rerender({ input: 'repo' }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)); }); + await waitFor(() => assert.equal(view.result.current.candidates.length, 1)); + assert.equal(view.result.current.target, null); + } finally { + fetch.restore(); + } +}); + +test('a user pick sticks through further typing until the input clears', async () => { + const fetch = installFetch(({ url }) => { + const text = new URL(url, 'http://local').searchParams.get('text') ?? ''; + if (!text) return { isWorkspace: true, candidates: [] }; + return { isWorkspace: true, candidates: [candidateA, candidateB] }; + }); + try { + const view = composer(); + await waitFor(() => assert.equal(view.result.current.isWorkspace, true)); + + act(() => { view.result.current.pickTarget(candidateB); }); + assert.equal(view.result.current.pinned, true); + assert.equal(view.result.current.target?.name, 'repo-b'); + + // Later typing resolves repo-a as the top candidate, but the pin wins. + view.rerender({ input: 'now about repo-a' }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)); }); + await waitFor(() => assert.equal(view.result.current.candidates.length, 2)); + assert.equal(view.result.current.target?.name, 'repo-b', 'the pinned pick is not overridden by later typing'); + + view.rerender({ input: '' }); + assert.equal(view.result.current.pinned, false, 'the pin releases immediately once the box empties'); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)); }); + await waitFor(() => assert.equal(view.result.current.target, null)); + } finally { + fetch.restore(); + } +}); + +test('establishing a session releases the pin', async () => { + const fetch = installFetch(({ url }) => { + const text = new URL(url, 'http://local').searchParams.get('text') ?? ''; + if (!text) return { isWorkspace: true, candidates: [] }; + return { isWorkspace: true, candidates: [candidateA] }; + }); + try { + const view = composer({ input: 'repo-a work' }); + await waitFor(() => assert.equal(view.result.current.isWorkspace, true)); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 320)); }); + act(() => { view.result.current.pickTarget(candidateA); }); + assert.equal(view.result.current.pinned, true); + + view.rerender({ input: 'repo-a work', selectedSession: session('s1') }); + assert.equal(view.result.current.pinned, false); + assert.equal(view.result.current.target, null); + } finally { + fetch.restore(); + } +}); + +test('resolveForSend authoritatively resolves submitted text before preview completes', async () => { + const fetch = installFetch(({ url }) => { + const text = new URL(url, 'http://local').searchParams.get('text') ?? ''; + return { isWorkspace: true, candidates: text ? [candidateA] : [] }; + }); + try { + const view = composer(); + const target = await view.result.current.resolveForSend('exact submitted text'); + assert.equal(target?.name, 'repo-a'); + assert.equal(new URL(fetch.calls[1].url, 'http://local').searchParams.get('text'), 'exact submitted text'); + assert.equal(view.result.current.isWorkspace, false); + } finally { + fetch.restore(); + } +}); + +test('resolveForSend honors a pinned root target without fetching', async () => { + const fetch = installFetch(() => ({ isWorkspace: true, candidates: [] })); + try { + const view = composer(); + await waitFor(() => assert.equal(fetch.calls.length, 1)); + act(() => { view.result.current.pickTarget(null); }); + assert.equal(await view.result.current.resolveForSend('repo-a'), null); + assert.equal(fetch.calls.length, 1); + } finally { + fetch.restore(); + } +}); + +test('resolveForSend throws on an unsuccessful response', async () => { + const original = globalThis.fetch; + globalThis.fetch = (async () => new Response('', { status: 503 })) as typeof fetch; + try { + const view = composer(); + await assert.rejects(() => view.result.current.resolveForSend('repo-a'), /503/); + } finally { + globalThis.fetch = original; + } +}); + +test('a late response from a previous project cannot overwrite the current project', async () => { + const original = globalThis.fetch; + let resolveA: ((response: Response) => void) | undefined; + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url; + if (url.includes('proj-workspace')) return new Promise((resolve) => { resolveA = resolve; }); + return Promise.resolve(new Response(JSON.stringify({ data: { isWorkspace: true, candidates: [candidateB] } }), { status: 200 })); + }) as typeof fetch; + try { + const view = composer(); + view.rerender({ selectedProject: { ...project, projectId: 'proj-b' } }); + await waitFor(() => assert.equal(view.result.current.candidates[0]?.name, 'repo-b')); + await act(async () => { resolveA?.(new Response(JSON.stringify({ data: { isWorkspace: true, candidates: [candidateA] } }), { status: 200 })); }); + assert.equal(view.result.current.candidates[0]?.name, 'repo-b'); + } finally { + globalThis.fetch = original; + } +}); diff --git a/src/components/chat/hooks/useWorkspaceTarget.ts b/src/components/chat/hooks/useWorkspaceTarget.ts new file mode 100644 index 00000000..8a661aee --- /dev/null +++ b/src/components/chat/hooks/useWorkspaceTarget.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { Project, ProjectSession } from '../../../types/app'; +import { authenticatedFetch } from '../../../utils/api'; + +export type WorkspaceCandidateReason = 'mention' | 'partial' | 'recent'; + +export interface WorkspaceCandidate { + path: string; + name: string; + score: number; + reason: WorkspaceCandidateReason; +} + +interface UseWorkspaceTargetArgs { + selectedProject: Project | null; + selectedSession: ProjectSession | null; + currentSessionId: string | null; + input: string; +} + +interface UseWorkspaceTargetResult { + isWorkspace: boolean; + candidates: WorkspaceCandidate[]; + /** The candidate a send would descend into, or null to stay at the workspace root. */ + target: WorkspaceCandidate | null; + pickTarget: (candidate: WorkspaceCandidate | null) => void; + /** True once the user has made an explicit pick that later typing must not override. */ + pinned: boolean; + resolveForSend: (text: string) => Promise; +} + +/** A resolved candidate strong enough to auto-target without the user picking it. */ +const AUTO_TARGET_SCORE = 80; +const RESOLVE_DEBOUNCE_MS = 300; +const RESOLVE_TEXT_MAX = 2000; + +/** + * Resolves, for a new task started in a workspace-root project (e.g. + * `~/Projects`), which child repo the first message is probably about. + * + * Only active for a brand-new task (no session yet) with a project selected. + * An empty-text probe on mount tells the composer whether the project is a + * workspace at all; once it is, typing re-queries the server (debounced) for + * scored candidates. A score at or above `AUTO_TARGET_SCORE` auto-targets the + * top candidate, but a user's own pick always wins until the input clears or + * the session is established, at which point the pin releases. + */ +export function useWorkspaceTarget({ selectedProject, selectedSession, currentSessionId, input }: UseWorkspaceTargetArgs): UseWorkspaceTargetResult { + const projectId = selectedProject?.projectId ?? null; + const isNewTask = !selectedSession && !currentSessionId; + const [isWorkspace, setIsWorkspace] = useState(false); + const [candidates, setCandidates] = useState([]); + const [pinnedTarget, setPinnedTarget] = useState(null); + const [pinned, setPinned] = useState(false); + const latestRequest = useRef(null); + + const resolve = useCallback(async (text: string) => { + if (!projectId) return; + const cappedText = text.slice(0, RESOLVE_TEXT_MAX); + const request = `${projectId}\n${cappedText}`; + latestRequest.current = request; + try { + const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/resolve-target?text=${encodeURIComponent(cappedText)}`); + if (latestRequest.current !== request) return; + if (!response.ok) { setIsWorkspace(false); setCandidates([]); return; } + const body = await response.json(); + if (latestRequest.current !== request) return; + setIsWorkspace(Boolean(body?.data?.isWorkspace)); + setCandidates(Array.isArray(body?.data?.candidates) ? body.data.candidates : []); + } catch { + if (latestRequest.current !== request) return; + setIsWorkspace(false); + setCandidates([]); + } + }, [projectId]); + + const resolveForSend = useCallback(async (text: string): Promise => { + if (!projectId || pinned) return pinnedTarget; + const cappedText = text.slice(0, RESOLVE_TEXT_MAX); + const response = await authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/resolve-target?text=${encodeURIComponent(cappedText)}`); + if (!response.ok) throw new Error(`Failed to resolve workspace target (${response.status})`); + const body = await response.json(); + if (!body?.data?.isWorkspace) return null; + const candidates = Array.isArray(body?.data?.candidates) ? body.data.candidates as WorkspaceCandidate[] : []; + return candidates.length && candidates[0].score >= AUTO_TARGET_SCORE ? candidates[0] : null; + }, [pinned, pinnedTarget, projectId]); + + // The empty-text probe: once per new-task mount/project change, tells the + // composer whether there is anything to resolve at all. + useEffect(() => { + if (!isNewTask || !projectId) { + setIsWorkspace(false); + setCandidates([]); + return; + } + void resolve(''); + }, [isNewTask, projectId, resolve]); + + // Debounced re-query as the user types, only once known to be a workspace. + useEffect(() => { + if (!isNewTask || !isWorkspace) return; + const timer = setTimeout(() => { void resolve(input); }, RESOLVE_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [input, isNewTask, isWorkspace, resolve]); + + // The pin releases when the box empties or the session is established; a + // fresh box means a fresh guess is due, and an established session has no + // more target to pick. + useEffect(() => { + if (!input.trim() || selectedSession || currentSessionId) { + setPinned(false); + setPinnedTarget(null); + } + }, [currentSessionId, input, selectedSession]); + + useEffect(() => { + setPinned(false); + setPinnedTarget(null); + }, [projectId]); + + const pickTarget = useCallback((candidate: WorkspaceCandidate | null) => { + setPinnedTarget(candidate); + setPinned(true); + }, []); + + const autoTarget = candidates.length > 0 && candidates[0].score >= AUTO_TARGET_SCORE ? candidates[0] : null; + const target = pinned ? pinnedTarget : autoTarget; + + return { isWorkspace, candidates, target, pickTarget, pinned, resolveForSend }; +} diff --git a/src/components/chat/view/ChatComposer.tsx b/src/components/chat/view/ChatComposer.tsx index 1fcca4ea..1b2ed742 100644 --- a/src/components/chat/view/ChatComposer.tsx +++ b/src/components/chat/view/ChatComposer.tsx @@ -17,6 +17,7 @@ import { classifyCommandInput, isAutoSendable } from '../commandDispatchPolicy'; import { useVoiceInput } from '../hooks/useVoiceInput'; import { useVoiceAvailable } from '../hooks/useVoiceAvailable'; import type { PendingCommandGate, QueuedDraft } from '../hooks/useChatComposerState'; +import type { WorkspaceCandidate } from '../hooks/useWorkspaceTarget'; import type { PendingPermissionRequest, PermissionDecision } from '../types/types'; import type { ProviderModelOption } from '../../../types/app'; import type { PermissionModeUpdate, ProjectPermissions } from '../../../hooks/useProjectPermissions'; @@ -44,6 +45,7 @@ import PermissionModePicker from './PermissionModePicker'; import ContextUsageBadge from './ContextUsageBadge'; import type { ReasoningEffort } from './reasoningEffort'; import SkillPicker from './SkillPicker'; +import WorkspaceTargetChip from './WorkspaceTargetChip'; interface MentionableFile { name: string; @@ -133,6 +135,12 @@ interface ChatComposerProps { permissions?: ProjectPermissions | null; onSelectPermissionMode?: (update: PermissionModeUpdate) => Promise | unknown; permissionsBusy?: boolean; + /** True when the selected project is a workspace root; shows the target chip. */ + isWorkspace?: boolean; + workspaceRootName?: string; + workspaceCandidates?: WorkspaceCandidate[]; + workspaceTarget?: WorkspaceCandidate | null; + onPickWorkspaceTarget?: (candidate: WorkspaceCandidate | null) => void; } export default function ChatComposer({ @@ -198,6 +206,11 @@ export default function ChatComposer({ permissions = null, onSelectPermissionMode = () => {}, permissionsBusy = false, + isWorkspace = false, + workspaceRootName = '', + workspaceCandidates = [], + workspaceTarget = null, + onPickWorkspaceTarget = () => {}, }: ChatComposerProps) { const { t } = useTranslation('chat'); const commandMenuPosition = useMemo(() => { @@ -304,6 +317,15 @@ export default function ChatComposer({ /> ))} + {isWorkspace && ( + + )} + {!hasQuestionPanel &&
{showFileDropdown && filteredFiles.length > 0 && (
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 5b85b842..11ed63cc 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -303,6 +303,11 @@ function ChatInterface({ permissions={projectPermissions.permissions} onSelectPermissionMode={projectPermissions.setMode} permissionsBusy={projectPermissions.isSettingMode} + isWorkspace={composer.isWorkspace} + workspaceRootName={selectedProject.displayName} + workspaceCandidates={composer.workspaceCandidates} + workspaceTarget={composer.workspaceTargetValue} + onPickWorkspaceTarget={composer.pickWorkspaceTarget} /> ); diff --git a/src/components/chat/view/WorkspaceTargetChip.dom.bun.test.tsx b/src/components/chat/view/WorkspaceTargetChip.dom.bun.test.tsx new file mode 100644 index 00000000..4e8950b5 --- /dev/null +++ b/src/components/chat/view/WorkspaceTargetChip.dom.bun.test.tsx @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; + +import { createElement } from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import '../../../i18n/config'; +import type { WorkspaceCandidate } from '../hooks/useWorkspaceTarget'; + +import WorkspaceTargetChip from './WorkspaceTargetChip'; + +/* + * The chip is the only visible surface of workspace-quick-task, so its two + * states - resolved target vs. still-at-root - and the picker it opens are + * what a user actually interacts with. A static render never opens the + * picker, so this mounts the real component. + */ + +const candidate = (overrides: Partial = {}): WorkspaceCandidate => ({ + path: '/Projects/gajae-code-app', + name: 'gajae-code-app', + score: 100, + reason: 'mention', + ...overrides, +}); + +afterEach(cleanup); + +test('no target shows the workspace root name with a choose-repo affordance', () => { + render(createElement(WorkspaceTargetChip, { + workspaceRootName: 'Projects', + candidates: [candidate()], + target: null, + onPick: () => {}, + })); + + const trigger = screen.getByRole('button'); + assert.match(trigger.textContent ?? '', /Projects/); + assert.match(trigger.textContent ?? '', /choose repo/); +}); + +test('a resolved target shows the arrow label instead of the root name', () => { + render(createElement(WorkspaceTargetChip, { + workspaceRootName: 'Projects', + candidates: [candidate()], + target: candidate(), + onPick: () => {}, + })); + + const trigger = screen.getByRole('button'); + assert.match(trigger.textContent ?? '', /→ gajae-code-app/); + assert.equal(screen.queryByText(/choose repo/), null); +}); + +test('opening the picker lists every candidate plus keep at root, and choosing one calls back', () => { + const picks: Array = []; + render(createElement(WorkspaceTargetChip, { + workspaceRootName: 'Projects', + candidates: [candidate(), candidate({ path: '/Projects/other-app', name: 'other-app', score: 40, reason: 'partial' })], + target: null, + onPick: (picked) => picks.push(picked), + })); + + fireEvent.click(screen.getByRole('button', { name: /choose repo/ })); + assert.ok(screen.getByRole('option', { name: /gajae-code-app/ })); + assert.ok(screen.getByRole('option', { name: /other-app/ })); + assert.ok(screen.getByRole('option', { name: 'Keep at root' })); + + fireEvent.click(screen.getByRole('option', { name: /other-app/ })); + assert.deepEqual(picks, [candidate({ path: '/Projects/other-app', name: 'other-app', score: 40, reason: 'partial' })]); + // Picking closes the popup. + assert.equal(screen.queryByRole('listbox'), null); +}); + +test('the picker filters by name and Enter picks the first match', () => { + const picks: Array = []; + render(createElement(WorkspaceTargetChip, { + workspaceRootName: 'Projects', + candidates: [ + candidate({ path: '/Projects/hf-studio', name: 'hf-studio', score: 0, reason: 'recent' }), + candidate({ path: '/Projects/moss-hq', name: 'moss-hq', score: 0, reason: 'recent' }), + candidate({ path: '/Projects/moss-companion', name: 'moss-companion', score: 0, reason: 'recent' }), + ], + target: null, + onPick: (picked) => picks.push(picked), + })); + + fireEvent.click(screen.getByRole('button', { name: /choose repo/ })); + const search = screen.getByRole('textbox', { name: /Search repos/ }); + fireEvent.change(search, { target: { value: 'MOSS' } }); + assert.equal(screen.queryByRole('option', { name: /hf-studio/ }), null); + assert.ok(screen.getByRole('option', { name: /moss-hq/ })); + assert.ok(screen.getByRole('option', { name: /moss-companion/ })); + + fireEvent.change(search, { target: { value: 'zzz' } }); + assert.ok(screen.getByText('No repo matches')); + + fireEvent.change(search, { target: { value: 'moss-c' } }); + fireEvent.keyDown(search, { key: 'Enter' }); + assert.deepEqual(picks.map((pick) => pick?.name), ['moss-companion']); +}); + +test('choosing keep at root reports a null target', () => { + const picks: Array = []; + render(createElement(WorkspaceTargetChip, { + workspaceRootName: 'Projects', + candidates: [candidate()], + target: candidate(), + onPick: (picked) => picks.push(picked), + })); + + fireEvent.click(screen.getByRole('button', { name: /gajae-code-app/ })); + fireEvent.click(screen.getByRole('option', { name: 'Keep at root' })); + + assert.deepEqual(picks, [null]); +}); diff --git a/src/components/chat/view/WorkspaceTargetChip.tsx b/src/components/chat/view/WorkspaceTargetChip.tsx new file mode 100644 index 00000000..a62b0d6c --- /dev/null +++ b/src/components/chat/view/WorkspaceTargetChip.tsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, FolderGit2, Search } from 'lucide-react'; + +import { cn } from '../../../utils/cn'; +import type { WorkspaceCandidate } from '../hooks/useWorkspaceTarget'; + +interface WorkspaceTargetChipProps { + /** Display name of the workspace root project, shown when no target is picked. */ + workspaceRootName: string; + candidates: WorkspaceCandidate[]; + target: WorkspaceCandidate | null; + onPick: (candidate: WorkspaceCandidate | null) => void; +} + +const POPUP_WIDTH = 320; +// Search row + 18rem list + keep-at-root row, the tallest the popup gets. +const POPUP_MAX_HEIGHT = 380; + +/** + * The workspace-quick-task chip: shown above the composer only for a new task + * started in a workspace-root project (e.g. `~/Projects`). Reads either + * "→ {child}" when a target is resolved or picked, or the workspace root name + * with a "choose repo" affordance when none is. Clicking either opens a + * searchable picker of every candidate repo plus "keep at root"; a workspace + * root routinely has dozens of repos, so the list scrolls and filters like the + * skill picker does. + */ +export default function WorkspaceTargetChip({ workspaceRootName, candidates, target, onPick }: WorkspaceTargetChipProps) { + const { t } = useTranslation('chat'); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const rootRef = useRef(null); + const popupRef = useRef(null); + const searchRef = useRef(null); + const [popupPosition, setPopupPosition] = useState<{ top?: number; bottom?: number; left: number }>({ bottom: 0, left: 0 }); + + // Same escape hatch as SkillPicker: the popup portals to the body and opens + // upward so a long repo list never covers the composer. On the empty-state + // screen the composer sits mid-viewport, so when there is not enough room + // above it opens downward instead of clipping at the top edge. + useEffect(() => { + if (!open) return; + setQuery(''); + const rect = rootRef.current?.getBoundingClientRect(); + if (rect) { + const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_WIDTH - 8)); + setPopupPosition(rect.top >= POPUP_MAX_HEIGHT + 16 + ? { bottom: window.innerHeight - rect.top + 8, left } + : { top: rect.bottom + 8, left }); + } + window.requestAnimationFrame(() => searchRef.current?.focus()); + const close = (event: MouseEvent) => { + const node = event.target as Node; + if (!rootRef.current?.contains(node) && !popupRef.current?.contains(node)) setOpen(false); + }; + const escape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', close); + document.addEventListener('keydown', escape); + return () => { + document.removeEventListener('mousedown', close); + document.removeEventListener('keydown', escape); + }; + }, [open]); + + const filtered = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return candidates; + return candidates.filter((candidate) => candidate.name.toLowerCase().includes(normalized)); + }, [candidates, query]); + + const pick = (candidate: WorkspaceCandidate | null) => { + onPick(candidate); + setOpen(false); + }; + + const label = target + ? t('workspaceTarget.target', { name: target.name }) + : `${workspaceRootName} · ${t('workspaceTarget.chooseRepo')}`; + + return ( +
+ + + {open && createPortal( +
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && filtered.length > 0) { + event.preventDefault(); + pick(filtered[0]); + } + }} + placeholder={t('workspaceTarget.search')} + aria-label={t('workspaceTarget.search')} + className="h-7 w-full rounded-md border border-input bg-background pr-2 pl-7 text-xs outline-hidden placeholder:text-muted-foreground focus:border-ring" + /> +
+
+ {filtered.map((candidate) => ( + + ))} + {filtered.length === 0 && ( +

{t('workspaceTarget.noMatch')}

+ )} +
+
+ +
+
, + document.body, + )} +
+ ); +} diff --git a/src/hooks/useProjectsState.query.dom.bun.test.tsx b/src/hooks/useProjectsState.query.dom.bun.test.tsx index 6a1e440c..d2726569 100644 --- a/src/hooks/useProjectsState.query.dom.bun.test.tsx +++ b/src/hooks/useProjectsState.query.dom.bun.test.tsx @@ -168,6 +168,60 @@ test('session upserts write directly to the project cache', async () => { } }); +test('an upsert carrying an origin promotes the cached project so the sidebar can show it', async () => { + // The indexer had only discovered the repo ('auto', hidden); a workspace + // descend registered it as explicit on the server and the session landed. + const fetch = installFetch([{ body: [{ ...project(), origin: 'auto' }] }]); + try { + const harness = renderHarness(); + await waitFor(() => assert.equal(harness.getState().projects.length, 1)); + + act(() => { + harness.emit({ + kind: 'session_upserted', + sessionId: 'session-descended', + provider: 'gjc', + session: { id: 'session-descended', summary: 'fix the build' }, + project: { + projectId: 'project-1', + path: '/workspace/project', + fullPath: '/workspace/project', + displayName: 'Project one', + isStarred: false, + origin: 'explicit', + }, + timestamp: new Date().toISOString(), + } as ServerEvent); + }); + + await waitFor(() => assert.equal(harness.getState().projects[0]?.origin, 'explicit')); + assert.deepEqual(harness.getState().projects[0]?.sessions?.map((session) => session.id), ['session-descended']); + assert.equal(fetch.calls(), 1); + } finally { + fetch.restore(); + } +}); + +test('upsert origin only promotes cached projects to explicit', async () => { + const fetch = installFetch([{ body: [{ ...project(), origin: 'explicit' }] }]); + try { + const harness = renderHarness(); + await waitFor(() => assert.equal(harness.getState().projects.length, 1)); + act(() => harness.emit({ + kind: 'session_upserted', sessionId: 'auto-event', provider: 'gjc', session: { id: 'auto-event' }, + project: { ...project(), origin: 'auto' }, timestamp: new Date().toISOString(), + } as ServerEvent)); + await waitFor(() => assert.equal(harness.getState().projects[0]?.origin, 'explicit')); + act(() => harness.emit({ + kind: 'session_upserted', sessionId: 'explicit-event', provider: 'gjc', session: { id: 'explicit-event' }, + project: { ...project(), origin: 'explicit' }, timestamp: new Date().toISOString(), + } as ServerEvent)); + assert.equal(harness.getState().projects[0]?.origin, 'explicit'); + } finally { + fetch.restore(); + } +}); + test('an upsert for the viewed session renames it where the header reads it, not only in the sidebar', async () => { const fetch = installFetch([{ body: [project()] }]); try { diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 1eff26c0..043c608d 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -14,7 +14,7 @@ import type { SessionActivityMap } from './useSessionProtection'; export { projectsHaveChanges, readProjectsResponse } from './useProjectsQuery'; type UseProjectsStateArgs = { sessionId?: string | null; navigate: NavigateFunction; subscribe: (listener: (event: ServerEvent) => void) => () => void; isMobile: boolean; activeSessions: SessionActivityMap }; -type SessionUpsert = ServerEvent & { sessionId: string; providerSessionId?: string | null; provider: LLMProvider; session: ProjectSession; project: { projectId: string; path: string; fullPath: string; displayName: string; isStarred: boolean } | null }; +type SessionUpsert = ServerEvent & { sessionId: string; providerSessionId?: string | null; provider: LLMProvider; session: ProjectSession; project: { projectId: string; path: string; fullPath: string; displayName: string; isStarred: boolean; origin?: Project['origin'] } | null }; type RegisterOptimisticSessionArgs = { sessionId: string; provider: LLMProvider; project: Project; summary?: string | null }; type ProjectSessionPage = Pick; type FetchProjectsOptions = { showLoadingState?: boolean }; @@ -99,7 +99,11 @@ const updateProjectCache = (projects: Project[], event: SessionUpsert): Project[ ? project.projectId === projectId : rowsOf(project).some((session) => session.id === event.sessionId)); if (found) { - const next = applySessionUpsert(found, event); + // The database only promotes discovered rows ('auto'/'legacy') to explicit; + // a later index event must never demote an explicit cached project. + const origin = event.project?.origin; + const promoted = origin === 'explicit' && found.origin !== 'explicit' ? { ...found, origin } : found; + const next = applySessionUpsert(promoted, event); return next === found ? projects : projects.map((project) => project === found ? next : project); } if (!event.project) return projects; @@ -139,7 +143,7 @@ export function useProjectsState({ sessionId, navigate, subscribe, isMobile, act if (!id || !project?.projectId) return; const now = new Date().toISOString(); const session: ProjectSession = { id, summary: summary ?? '', messageCount: 0, createdAt: now, created_at: now, updated_at: now, lastActivity: now, __provider: provider, __projectId: project.projectId }; - const event: SessionUpsert = { kind: 'session_upserted', sessionId: id, provider, session, project: { projectId: project.projectId, path: project.path || project.fullPath, fullPath: project.fullPath || project.path || '', displayName: project.displayName, isStarred: Boolean(project.isStarred) }, timestamp: now }; + const event: SessionUpsert = { kind: 'session_upserted', sessionId: id, provider, session, project: { projectId: project.projectId, path: project.path || project.fullPath, fullPath: project.fullPath || project.path || '', displayName: project.displayName, isStarred: Boolean(project.isStarred), origin: project.origin }, timestamp: now }; client.setQueryData(PROJECTS_QUERY_KEY, (cached) => updateProjectCache(cached ?? [], event)); setSelectedProject((current) => current?.projectId === project.projectId ? applySessionUpsert(current, event) : current); setSelectedSession((current) => current?.id === id ? { ...current, ...session } : session); diff --git a/src/i18n/locales/de/chat.json b/src/i18n/locales/de/chat.json index 0090dcd8..8c3c0da9 100644 --- a/src/i18n/locales/de/chat.json +++ b/src/i18n/locales/de/chat.json @@ -224,5 +224,17 @@ "generating": "Plan wird erstellt...", "revise": "Überarbeiten", "build": "Umsetzen" + }, + "workspaceTarget": { + "chooseRepo": "Repo wählen", + "keepAtRoot": "Im Stammverzeichnis bleiben", + "target": "→ {{name}}", + "search": "Repos durchsuchen…", + "noMatch": "Kein passendes Repo", + "reason": { + "mention": "Erwähnt", + "partial": "Teilweise Übereinstimmung", + "recent": "Kürzlich aktiv" + } } } diff --git a/src/i18n/locales/en/chat.json b/src/i18n/locales/en/chat.json index 42d13823..f8121e5a 100644 --- a/src/i18n/locales/en/chat.json +++ b/src/i18n/locales/en/chat.json @@ -224,5 +224,17 @@ "generating": "Generating plan...", "revise": "Revise", "build": "Build" + }, + "workspaceTarget": { + "chooseRepo": "choose repo", + "keepAtRoot": "Keep at root", + "target": "→ {{name}}", + "search": "Search repos…", + "noMatch": "No repo matches", + "reason": { + "mention": "Mentioned", + "partial": "Partial match", + "recent": "Recently active" + } } } diff --git a/src/i18n/locales/fr/chat.json b/src/i18n/locales/fr/chat.json index 10f191e8..669c0a77 100644 --- a/src/i18n/locales/fr/chat.json +++ b/src/i18n/locales/fr/chat.json @@ -224,5 +224,17 @@ "generating": "Génération du plan...", "revise": "Réviser", "build": "Construire" + }, + "workspaceTarget": { + "chooseRepo": "choisir un dépôt", + "keepAtRoot": "Rester à la racine", + "target": "→ {{name}}", + "search": "Rechercher un dépôt…", + "noMatch": "Aucun dépôt correspondant", + "reason": { + "mention": "Mentionné", + "partial": "Correspondance partielle", + "recent": "Récemment actif" + } } } diff --git a/src/i18n/locales/it/chat.json b/src/i18n/locales/it/chat.json index 192bdd4a..facd1d24 100644 --- a/src/i18n/locales/it/chat.json +++ b/src/i18n/locales/it/chat.json @@ -224,5 +224,17 @@ "generating": "Generazione del piano...", "revise": "Rivedi", "build": "Procedi" + }, + "workspaceTarget": { + "chooseRepo": "scegli repo", + "keepAtRoot": "Resta nella radice", + "target": "→ {{name}}", + "search": "Cerca repo…", + "noMatch": "Nessun repo corrispondente", + "reason": { + "mention": "Menzionato", + "partial": "Corrispondenza parziale", + "recent": "Attivo di recente" + } } } diff --git a/src/i18n/locales/ja/chat.json b/src/i18n/locales/ja/chat.json index bb920faa..ed124cc6 100644 --- a/src/i18n/locales/ja/chat.json +++ b/src/i18n/locales/ja/chat.json @@ -224,5 +224,17 @@ "generating": "計画を生成中...", "revise": "修正", "build": "実行" + }, + "workspaceTarget": { + "chooseRepo": "リポジトリを選択", + "keepAtRoot": "ルートのまま", + "target": "→ {{name}}", + "search": "リポジトリを検索…", + "noMatch": "一致するリポジトリなし", + "reason": { + "mention": "言及あり", + "partial": "部分一致", + "recent": "最近の活動" + } } } diff --git a/src/i18n/locales/ko/chat.json b/src/i18n/locales/ko/chat.json index fc2f40e9..0ee2a2e7 100644 --- a/src/i18n/locales/ko/chat.json +++ b/src/i18n/locales/ko/chat.json @@ -224,5 +224,17 @@ "generating": "계획 생성 중...", "revise": "수정", "build": "진행" + }, + "workspaceTarget": { + "chooseRepo": "저장소 선택", + "keepAtRoot": "루트에 유지", + "target": "→ {{name}}", + "search": "저장소 검색…", + "noMatch": "일치하는 저장소 없음", + "reason": { + "mention": "언급됨", + "partial": "부분 일치", + "recent": "최근 활동" + } } } diff --git a/src/i18n/locales/ru/chat.json b/src/i18n/locales/ru/chat.json index 73972184..67df5b3e 100644 --- a/src/i18n/locales/ru/chat.json +++ b/src/i18n/locales/ru/chat.json @@ -244,5 +244,17 @@ "generating": "Создание плана...", "revise": "Доработать", "build": "Выполнить" + }, + "workspaceTarget": { + "chooseRepo": "выбрать репозиторий", + "keepAtRoot": "Остаться в корне", + "target": "→ {{name}}", + "search": "Поиск репозиториев…", + "noMatch": "Нет подходящих репозиториев", + "reason": { + "mention": "Упомянут", + "partial": "Частичное совпадение", + "recent": "Недавняя активность" + } } } diff --git a/src/i18n/locales/tr/chat.json b/src/i18n/locales/tr/chat.json index 235e3e99..02054fd4 100644 --- a/src/i18n/locales/tr/chat.json +++ b/src/i18n/locales/tr/chat.json @@ -224,5 +224,17 @@ "generating": "Plan oluşturuluyor...", "revise": "Gözden geçir", "build": "Uygula" + }, + "workspaceTarget": { + "chooseRepo": "depo seç", + "keepAtRoot": "Kökte kal", + "target": "→ {{name}}", + "search": "Depolarda ara…", + "noMatch": "Eşleşen depo yok", + "reason": { + "mention": "Bahsedildi", + "partial": "Kısmi eşleşme", + "recent": "Son etkinlik" + } } } diff --git a/src/i18n/locales/zh-CN/chat.json b/src/i18n/locales/zh-CN/chat.json index 28b99381..92a2f614 100644 --- a/src/i18n/locales/zh-CN/chat.json +++ b/src/i18n/locales/zh-CN/chat.json @@ -224,5 +224,17 @@ "generating": "正在生成计划...", "revise": "修改", "build": "执行" + }, + "workspaceTarget": { + "chooseRepo": "选择仓库", + "keepAtRoot": "保留在根目录", + "target": "→ {{name}}", + "search": "搜索仓库…", + "noMatch": "没有匹配的仓库", + "reason": { + "mention": "已提及", + "partial": "部分匹配", + "recent": "最近活跃" + } } } diff --git a/src/i18n/locales/zh-TW/chat.json b/src/i18n/locales/zh-TW/chat.json index 190680f5..5fcfcb60 100644 --- a/src/i18n/locales/zh-TW/chat.json +++ b/src/i18n/locales/zh-TW/chat.json @@ -224,5 +224,17 @@ "generating": "正在產生計畫...", "revise": "修改", "build": "執行" + }, + "workspaceTarget": { + "chooseRepo": "選擇儲存庫", + "keepAtRoot": "保留在根目錄", + "target": "→ {{name}}", + "search": "搜尋儲存庫…", + "noMatch": "沒有符合的儲存庫", + "reason": { + "mention": "已提及", + "partial": "部分符合", + "recent": "最近活躍" + } } }