mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-13 16:24:43 +00:00
Refactor provider/session architecture to be DB-driven, modular, and sessionId-first across backend and frontend (#715)
* refactor: remove unused exports
* refactor: remove unused fields from project and session objects
* refactor: rename session_names table and related code to sessions for clarity and consistency
* refactor(database): move db into typescript
- Implemented githubTokensDb for managing GitHub tokens with CRUD operations.
- Created
otificationPreferencesDb to handle user notification preferences.
- Added projectsDb for project path management and related operations.
- Introduced pushSubscriptionsDb for managing browser push subscriptions.
- Developed scanStateDb to track the last scanned timestamp.
- Established sessionsDb for session management with CRUD functionalities.
- Created userDb for user management, including authentication and onboarding.
- Implemented apidKeysDb for storing and managing VAPID keys.
feat(database): define schema for new database tables
- Added SQL schema definitions for users, API keys, user credentials, notification preferences, VAPID keys, push subscriptions, projects, sessions, scan state, and app configuration.
- Included necessary indexes for performance optimization.
refactor(shared): enhance type definitions and utility functions
- Updated shared types and interfaces for improved clarity and consistency.
- Added new types for credential management and provider-specific operations.
- Refined utility functions for better error handling and message normalization.
* feat: added session indexer logic
* perf(projects): lazy-load TaskMaster metadata per selected project
Why:
- /api/projects is a hot path (initial load, sidebar refresh, websocket sync).
- Scanning .taskmaster for every project on each call added avoidable fs I/O and payload size.
- TaskMaster metadata is only needed after selecting a specific project.
- Moving it to a project-scoped endpoint makes loading cost match user intent.
- The UI now hydrates TaskMaster state on selection and keeps it across refresh events.
- This prevents status flicker/regression while still removing global scan overhead.
- Selection fetches are sequence-guarded to block stale async responses on fast switching.
- isManuallyAdded was removed from responses to keep the public project contract minimal.
- Project dumps now use incrementing snapshot files to preserve history for debugging.
What changed:
- Added GET /api/projects/:projectName/taskmaster and getProjectTaskMaster().
- Removed TaskMaster detection from bulk getProjects().
- Added api.projectTaskmaster(...) plus selection-time hydration in frontend contexts.
- Merged cached taskmaster values into refreshed project lists for continuity.
- Removed isManuallyAdded from manual project payloads.
* refactor: update import paths for database modules and remove legacy db.js and schema.js files
* refactor(projects): identify projects by DB projectId instead of folder-derived name
GET /api/projects used to scan ~/.claude/projects/ on every request, derive
each project's identity from the encoded folder name, and re-parse JSONL
files to build session lists. Using the folder-derived name as the project
identifier leaked the Claude CLI's on-disk encoding into every API route,
forced every downstream endpoint to re-resolve a real path via JSONL
'cwd' inspection, and made the project list endpoint O(projects x sessions)
on disk I/O.
This change switches the entire API surface to identify projects by the
stable primary key from the 'projects' table and drives the listing
straight from the DB:
- Add projectsDb.getProjectPathById as the canonical projectId -> path
resolver so routes no longer need to touch the filesystem to figure out
where a project lives.
- Rewrite getProjects so it reads the project list from the 'projects'
table and the per-project session list from the 'sessions' table (one
SELECT per project). No filesystem scanning happens for this endpoint
anymore, which removes the dependency on ~/.claude/projects existing,
on Cursor's MD5-hashed chat folders being discoverable, and on Codex's
JSONL history being on disk. Per the migration spec each session now
exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0
(message counting is not implemented), and sessionMeta.hasMore is
pinned to false since this endpoint doesn't drive session pagination.
- Introduce id-based wrappers (getSessionsById, renameProjectById,
deleteSessionById, deleteProjectById, getProjectTaskMasterById) so
every caller can pass projectId and resolve the real path through the
DB. renameProjectById also writes to projects.custom_project_name so
the DB-driven getProjects response reflects renames immediately; it
keeps project-config.json in sync for any legacy reader that still
consults the JSON file.
- Migrate every /api/projects/:projectName route in server/index.js,
server/routes/taskmaster.js, and server/routes/messages.js to
:projectId, and change server/routes/git.js so the 'project'
query/body parameter carries a projectId that is resolved through the
DB before any git command runs. TaskMaster WebSocket broadcasts emit
'projectId' for the same reason so the frontend can match
notifications against its current selection without another lookup.
- Delete helpers that existed only to feed the old getProjects path
(getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along
with their unused imports (better-sqlite3's Database,
applyCustomSessionNames). The legacy folder-name helpers (getSessions,
renameProject, deleteSession, deleteProject, extractProjectDirectory)
are kept as internal implementation details of the id-based wrappers
and of destructive cleanup / conversation search, but they are no
longer re-exported.
- searchConversations still walks JSONL to produce match snippets (that
data doesn't live in the DB), but it now includes the resolved
projectId in each result so the sidebar can cross-reference hits with
its already loaded project list without a second round-trip.
Frontend migration:
- Project.name is replaced by Project.projectId in src/types/app.ts, and
ProjectSession.__projectName becomes __projectId so session tagging
and sidebar state keys stay aligned with the backend identifier.
Settings continues to use SettingsProject.name for legacy consumers,
but it is populated from projectId by normalizeProjectForSettings.
- All places that previously indexed per-project state by project.name
(sidebar expanded/starred/loading/deletingProjects sets,
additionalSessions map, projectHasMoreOverrides, starredProjects
localStorage, command history and draft-input localStorage,
TaskMaster caches) now key on projectId so state survives
display-name edits and is consistent across the app.
- src/utils/api.js renames every endpoint parameter to projectId, the
unified messages endpoint takes projectId in its query string, and
useSessionStore forwards projectId on fetchFromServer / fetchMore /
refreshFromServer. Git panel, file tree, code editor, PRD editor,
plugins context, MCP server flows and TaskMaster hooks are all
updated to pass projectId.
- DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default'
projectId sentinel so the empty-shell placeholder still satisfies the
Project contract.
Bug fix bundled in:
- sessionsDb.setName no longer bumps updated_at when a row already
exists. Renaming is a label change, not activity, so there is no
reason for it to reset 'last activity' in the sidebar. It also no
longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive
'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and
caused renamed sessions to appear shifted backwards by the client's
UTC offset. When an INSERT actually happens it now writes ISO-8601
UTC with a 'Z' suffix.
- buildSessionsByProviderFromDb normalizes any legacy naive timestamps
in the sessions table to ISO-8601 UTC on the way out so rows written
before this change also render correctly on the client.
Other cleanup:
- Removed the filesystem-first project-discovery comment block at the
top of server/projects.js and replaced it with a short note that
describes the new DB-driven flow and lists the few remaining
filesystem-dependent helpers (message reads, search, destructive
delete, manual project registration).
- server/modules/providers/index.ts is added as a small barrel so the
providers module exposes a stable public surface.
Made-with: Cursor
* refactor(projects): reorganize project-related logic into dedicated modules
* refactor(projects): rename getProjects with getProjectsWithSessions
* refactor: update import path for getProjectsWithSessions to include file extension
* refactor: use updated session watcher
In addition, for projects_updated websocket response, send the sessionId instead
* refactor(websocket): move websocket logic to its own module
* refactor(sessions-watcher): remove redundant logging after session sync completion
* refactor(index.js): reorganize code structure
* refactor(index.js): fix import order
* refactor: remove unnecessary GitHub cloning logic from create-workspace endpoint
* refactor: modularize project services, and wizard create/clone flow
Restructure project creation, listing, GitHub clone progress, and TaskMaster
details behind a dedicated TypeScript module under server/modules/projects/,
and align the client wizard with a single path-based flow.
Server / routing
- Remove server/routes/projects.js and mount server/modules/projects/
projects.routes.ts at /api/projects (still behind authenticateToken).
- Drop duplicate handlers from server/index.js for GET /api/projects and
GET /api/projects/:projectId/taskmaster; those live on the new router.
- Import WORKSPACES_ROOT and validateWorkspacePath from shared utils in
index.js instead of the deleted projects route module.
Projects router (projects.routes.ts)
- GET /: list projects with sessions (existing snapshot behavior).
- POST /create-project: validate body, reject legacy workspaceType and
mixed clone fields, delegate to createProject service, return distinct
success copy when an archived path is reactivated.
- GET /clone-progress: Server-Sent Events for clone progress/complete/error;
requires authenticated user id for token resolution; wires startCloneProject.
- GET /:projectId/taskmaster: delegates to getProjectTaskMaster.
Services (new)
- project-management.service.ts: path validation, workspace directory
creation, persistence via projectsDb.createProjectPath, mapping to API
project shape; surfaces AppError for validation, conflict, and not-found
cases; optional dependency injection for tests.
- project-clone.service.ts: validates workspace, resolves GitHub auth
(stored token or inline token), runs git clone with progress callbacks,
registers project via createProject on success; sanitizes errors and
supports cancellation; injectable dependencies for tests.
- projects-has-taskmaster.service.ts: moves TaskMaster detection and
normalization out of server/projects.js; resolve-by-id and public
getProjectTaskMaster with structured AppError responses.
Persistence and shared types
- projectsDb.createProjectPath now returns CreateProjectPathResult
(created | reactivated_archived | active_conflict) using INSERT … ON
CONFLICT with selective update when the row is archived; normalizes
display name from path or custom name; repository row typing moves to
shared ProjectRepositoryRow.
- getProjectPaths() returns only non-archived rows (isArchived = 0).
- shared/types.ts: ProjectRepositoryRow, CreateProjectPathResult/outcome,
WorkspacePathValidationResult.
- shared/utils.ts: WORKSPACES_ROOT, forbidden path lists, validateWorkspacePath,
asyncHandler for Express async routes.
Legacy cleanup
- server/projects.js: remove detectTaskMasterFolder, normalizeTaskMasterInfo,
and getProjectTaskMasterById (logic lives in the new service).
- server/routes/agent.js: register external API project paths with
projectsDb.createProjectPath instead of addProjectManually try/catch;
treat active_conflict as an existing registration and continue.
Tests
- Add Node test suites for project-management, project-clone, and
projects-has-taskmaster services; update projects.service test import
for renamed projects-with-sessions-fetch.service.ts.
Rename
- projects.service.ts → projects-with-sessions-fetch.service.ts;
re-export from modules/projects/index.ts.
Client (project creation wizard)
- Remove StepTypeSelection and workspaceType from form state and types;
wizard is two steps (configure path/GitHub auth, then review).
- createWorkspaceRequest → createProjectRequest; clone vs create-only
inferred from githubUrl (pathUtils / isCloneWorkflow).
- Adjust step indices, WizardProgress, StepConfiguration/Review,
WorkspacePathField, and src/utils/api.js as needed for the new API.
Docs
- Minor websocket README touch-up.
Net: ~1.6k insertions / ~0.9k deletions across 29 files; behavior is
centralized in typed services with explicit HTTP errors and test seams.
* refactor: remove loading sessions logic from sidebar
* refactor: move project rename to module
* refactor: move project deletion to module
* refactor: move project star state from localStorage to backend
* refactor: implement optimistic UI for project star state management
* feat: optimistic update for session watcher
* fix(projects-state): stop websocket message reprocessing loop
The websocket projects effect in useProjectsState could re-handle the same
latestMessage after local state writes triggered re-renders.
Under bursty websocket traffic, this created an update feedback cycle
that surfaced as 'Maximum update depth exceeded', often from Sidebar.
What changed:
- Added lastHandledMessageRef so each latestMessage object is handled once.
- Added an early return guard when the current message was already handled.
- Made projects updates idempotent by comparing previous and merged payloads
before calling setProjects.
Result:
- Breaks the effect -> state update -> effect re-entry cycle.
- Reduces redundant renders during rapid projects_updated traffic while
preserving normal project/session synchronization.
* refactor: optimize project auto-expand logic
* refactor: move projects provider specific logic into respective session providers
* refactor: move rename and delete sessions to modules
* refactor: move fetching messages to module
* fix: remove unused var
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* Potential fix for pull request finding 'Useless assignment to local variable'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
* refactor(projects/sidebar): remove temp snapshot side-effects and simplify session metadata UX
Why this change was needed:
- Project listing had an implicit side effect: every fetch wrote a debug snapshot under `.tmp/project-dumps`.
That added unnecessary disk I/O to a hot path, introduced hidden runtime behavior, and created maintenance
overhead for code that was not part of product functionality.
- Keeping snapshot-specific exports/tests around made the projects module API broader than needed and coupled
tests to temporary/debug behavior instead of user-visible behavior.
- Codex sessions could remain stuck with a placeholder name (`Untitled Codex Session`) even after a real title
became available from newer sync data, which degraded session discoverability in the UI.
- Sidebar session rows showed duplicated provider branding and long-form relative times, which added visual noise
and reduced scan speed when many sessions are listed.
What changed:
- Removed temporary projects snapshot dumping from `projects-with-sessions-fetch.service.ts`:
- deleted snapshot types/helpers and file-write flow
- removed the write call from `getProjectsWithSessions`
- Removed snapshot-related surface area from `projects/index.ts`.
- Removed the snapshot-focused test `projects.service.test.ts` that only validated removed debug behavior.
- Updated `codex-session-synchronizer.provider.ts` to upgrade session names when an existing session still has
the placeholder title but a real parsed name is now available.
- Updated `SidebarSessionItem.tsx`:
- removed duplicate provider logo rendering in each session row
- moved age indicator to the right side
- made age indicator fade on hover to prioritize action controls
- switched to compact relative time format (`<1m`, `Xm`, `Xhr`, `Xd`) for faster list scanning
Outcome:
- Lower overhead and fewer hidden side effects in project fetches.
- Cleaner module boundaries in projects.
- Better Codex session naming consistency after sync.
- Cleaner sidebar density and clearer hover/action behavior.
* refactor: implement pagination for project sessions loading
* refactor: move search to module
* fix: search performance
* refactor: add handling for internal Codex metadata in conversation search
* fix(migrations,projects,clone): normalize legacy schema before writes and harden conflict detection
Why
- Legacy installs can have a sessions table shape that predates provider/custom_name columns. Running migrateLegacySessionNames first caused its INSERT OR REPLACE INTO sessions (...) to target columns that may not exist and fail during startup migration.
- Some upgraded databases had projects.project_id as plain TEXT instead of a real PRIMARY KEY. That breaks assumptions used by id-based lookups and can allow invalid/duplicate identity semantics over time.
- projectsDb.createProjectPath inferred outcomes from
ow.isArchived, but the upsert path always returns the post-update row with isArchived=0, so archived-reactivation and fresh-create could be misclassified.
- git clone accepted user-controlled URLs directly in argv position, so inputs beginning with - could be interpreted as options instead of a repository argument.
What
- Added
ebuildProjectsTableWithPrimaryKeySchema in migrations: detect table shape via getTableInfo('projects'), verify project_id has pk=1, and rebuild when missing.
- Rebuild flow now creates a canonical projects__new table (project_id TEXT PRIMARY KEY), copies rows with transformation, backfills empty ids via SQLITE_UUID_SQL, deduplicates conflicting ids/paths, then swaps tables inside a transaction.
- Replaced the prior ddColumnToTableIfNotExists(...) + UPDATE project_id sequence with PK-aware detection/rebuild logic so legacy DBs converge to the required schema.
- Reordered migration sequence to run
ebuildSessionsTableWithProjectSchema before migrateLegacySessionNames, ensuring sessions is normalized before legacy session_names merge writes execute.
- Updated projectsDb.createProjectPath to generate an ttemptedId before insert, pass it into the prepared statement, and classify outcomes by comparing returned
ow.project_id to ttemptedId (created vs
eactivated_archived), with no-row remaining ctive_conflict.
- Hardened clone execution by inserting -- before clone URL in git argv and rejecting normalized GitHub URLs that start with - in startCloneProject.
Tests
- Added integration coverage for projectsDb.createProjectPath branches: fresh insert, archived reactivation, and active conflict.
- Added clone service test for option-prefixed githubUrl rejection (INVALID_GITHUB_URL).
* refactor(session-synchronizer): update last scanned timestamp based on synchronization results
* refactor: improve session limit and offset validation in provider routes
* refactor: normalize project paths across database and service modules
* refactor(database): make session id the primary key in sessions table
* fix(codex): preserve reasoning entries as thinking blocks
Codex history normalization was downgrading reasoning into plain assistant text
because of branch ordering, not because the raw data was missing.
Why this mattered:
- Codex reasoning JSONL entries are intentionally mapped to history items with
type thinking, but they also carry message.role assistant.
- normalizeHistoryEntry evaluated the assistant-role branch before the
thinking branch.
- As a result, reasoning content matched the assistant-text path first and was
emitted as kind text instead of kind thinking.
- This collapses semantic intent, so UI and downstream features that rely on
thinking blocks (separate rendering, filtering, and interpretation of model
thought process vs final answer) receive the wrong message kind.
What changed:
- Prioritized thinking detection (raw.type === thinking or raw.isReasoning)
before role-based assistant normalization.
- Kept a non-empty content guard for thinking payloads to avoid emitting empty
artifacts.
Impact:
- Reasoning entries from persisted Codex JSONL now remain thinking blocks
end-to-end.
- Regular assistant text normalization behavior remains unchanged.
* refactor: remove dead code
* refactor: directly use getProjectPathById from projectsDb
* refactor: add gemini jsonl session support
This commit is contained in:
@@ -135,7 +135,9 @@ export function useChatComposerState({
|
||||
}: UseChatComposerStateArgs) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
// Draft inputs are keyed by the DB projectId so per-project drafts
|
||||
// survive display-name changes.
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
@@ -276,9 +278,11 @@ export function useChatComposerState({
|
||||
const args =
|
||||
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
||||
|
||||
// The `/api/commands/execute` context sends `projectId` now instead of
|
||||
// a folder-derived project name; the path is still included verbatim.
|
||||
const context = {
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
projectName: selectedProject.name,
|
||||
projectId: selectedProject.projectId,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
@@ -503,7 +507,7 @@ export function useChatComposerState({
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||||
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: formData,
|
||||
@@ -669,7 +673,7 @@ export function useChatComposerState({
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
},
|
||||
[
|
||||
selectedSession,
|
||||
@@ -712,22 +716,22 @@ export function useChatComposerState({
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.projectId}`) || '';
|
||||
setInput((previous) => {
|
||||
const next = previous === savedInput ? previous : savedInput;
|
||||
inputValueRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [selectedProject?.name]);
|
||||
}, [selectedProject?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
if (input !== '') {
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||||
safeLocalStorage.setItem(`draft_input_${selectedProject.projectId}`, input);
|
||||
} else {
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
safeLocalStorage.removeItem(`draft_input_${selectedProject.projectId}`);
|
||||
}
|
||||
}, [input, selectedProject]);
|
||||
|
||||
|
||||
@@ -241,7 +241,8 @@ export function useChatSessionState({
|
||||
try {
|
||||
const slot = await sessionStore.fetchMore(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
// DB-assigned projectId replaces the legacy folder-derived name.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
});
|
||||
@@ -296,7 +297,7 @@ export function useChatSessionState({
|
||||
topLoadLockRef.current = false;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
setIsUserScrolledUp(false);
|
||||
}, [selectedProject?.name, selectedSession?.id]);
|
||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||
|
||||
// Initial scroll to bottom
|
||||
useEffect(() => {
|
||||
@@ -325,7 +326,7 @@ export function useChatSessionState({
|
||||
}
|
||||
|
||||
const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`;
|
||||
const sessionKey = `${selectedSession.id}:${selectedProject.projectId}:${provider}`;
|
||||
|
||||
// Skip if already loaded and fresh
|
||||
if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {
|
||||
@@ -375,7 +376,7 @@ export function useChatSessionState({
|
||||
setIsLoadingSessionMessages(true);
|
||||
sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: MESSAGES_PER_PAGE,
|
||||
offset: 0,
|
||||
@@ -411,7 +412,7 @@ export function useChatSessionState({
|
||||
if (!isLoading) {
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || provider) as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
|
||||
@@ -469,7 +470,7 @@ export function useChatSessionState({
|
||||
// Load all messages into the store for search navigation
|
||||
const slot = await sessionStore.fetchFromServer(selectedSession.id, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
@@ -550,7 +551,8 @@ export function useChatSessionState({
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
// Token usage endpoint is now keyed by the DB projectId.
|
||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
@@ -656,7 +658,7 @@ export function useChatSessionState({
|
||||
try {
|
||||
const slot = await sessionStore.fetchFromServer(requestSessionId, {
|
||||
provider: sessionProvider as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
limit: null,
|
||||
offset: 0,
|
||||
|
||||
@@ -59,16 +59,18 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
|
||||
const abortController = new AbortController();
|
||||
|
||||
const fetchProjectFiles = async () => {
|
||||
const projectName = selectedProject?.name;
|
||||
// File list is keyed by DB projectId now; the backend resolves it to
|
||||
// the project's path before reading.
|
||||
const projectId = selectedProject?.projectId;
|
||||
setFileList([]);
|
||||
setFilteredFiles([]);
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await api.getFiles(projectName, { signal: abortController.signal });
|
||||
const response = await api.getFiles(projectId, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +90,7 @@ export function useFileMentions({ selectedProject, input, setInput, textareaRef
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [selectedProject?.name]);
|
||||
}, [selectedProject?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||||
|
||||
@@ -114,7 +114,7 @@ export function useSlashCommands({
|
||||
})),
|
||||
];
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
const parsedHistory = readCommandHistory(selectedProject.projectId);
|
||||
const sortedCommands = [...allCommands].sort((commandA, commandB) => {
|
||||
const commandAUsage = parsedHistory[commandA.name] || 0;
|
||||
const commandBUsage = parsedHistory[commandB.name] || 0;
|
||||
@@ -173,7 +173,7 @@ export function useSlashCommands({
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
const parsedHistory = readCommandHistory(selectedProject.projectId);
|
||||
|
||||
return slashCommands
|
||||
.map((command) => ({
|
||||
@@ -191,9 +191,9 @@ export function useSlashCommands({
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedHistory = readCommandHistory(selectedProject.name);
|
||||
const parsedHistory = readCommandHistory(selectedProject.projectId);
|
||||
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
||||
saveCommandHistory(selectedProject.name, parsedHistory);
|
||||
saveCommandHistory(selectedProject.projectId, parsedHistory);
|
||||
},
|
||||
[selectedProject],
|
||||
);
|
||||
|
||||
@@ -212,7 +212,8 @@ function ChatInterface({
|
||||
const providerVal = (localStorage.getItem('selected-provider') as LLMProvider) || 'claude';
|
||||
await sessionStore.refreshFromServer(selectedSession.id, {
|
||||
provider: (selectedSession.__provider || providerVal) as LLMProvider,
|
||||
projectName: selectedProject.name,
|
||||
// Use DB projectId; legacy folder-derived projectName is no longer accepted here.
|
||||
projectId: selectedProject.projectId,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path || '',
|
||||
});
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -23,7 +23,10 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const fileProjectName = file.projectName ?? projectPath;
|
||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||
// propagate the identifier.
|
||||
const fileProjectId = file.projectId ?? projectPath;
|
||||
const filePath = file.path;
|
||||
const fileName = file.name;
|
||||
const fileDiffNewString = file.diffInfo?.new_string;
|
||||
@@ -49,11 +52,11 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileProjectName) {
|
||||
if (!fileProjectId) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.readFile(fileProjectName, filePath);
|
||||
const response = await api.readFile(fileProjectId, filePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
@@ -70,18 +73,18 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
|
||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (!fileProjectName) {
|
||||
if (!fileProjectId) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.saveFile(fileProjectName, filePath, content);
|
||||
const response = await api.saveFile(fileProjectId, filePath, content);
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
@@ -106,7 +109,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, fileProjectName]);
|
||||
}, [content, filePath, fileProjectId]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
|
||||
@@ -29,11 +29,13 @@ export const useEditorSidebar = ({
|
||||
setEditingFile({
|
||||
name: fileName,
|
||||
path: filePath,
|
||||
projectName: selectedProject?.name,
|
||||
// DB projectId is forwarded to the editor so it can read/save files
|
||||
// via `/api/projects/:projectId/file` endpoints.
|
||||
projectId: selectedProject?.projectId,
|
||||
diffInfo,
|
||||
});
|
||||
},
|
||||
[selectedProject?.name],
|
||||
[selectedProject?.projectId],
|
||||
);
|
||||
|
||||
const handleCloseEditor = useCallback(() => {
|
||||
|
||||
@@ -7,7 +7,9 @@ export type CodeEditorDiffInfo = {
|
||||
export type CodeEditorFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
projectName?: string;
|
||||
// DB projectId; used by the editor to build `/api/projects/:projectId/file`
|
||||
// URLs for reading and saving content.
|
||||
projectId?: string;
|
||||
diffInfo?: CodeEditorDiffInfo | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -20,9 +20,11 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const projectName = selectedProject?.name;
|
||||
// File-tree requests use the DB projectId; the backend resolves it to the
|
||||
// project's absolute path through the projects table.
|
||||
const projectId = selectedProject?.projectId;
|
||||
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -42,7 +44,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });
|
||||
const response = await api.getFiles(projectId, { signal: abortControllerRef.current!.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -79,7 +81,7 @@ export function useFileTreeData(selectedProject: Project | null): UseFileTreeDat
|
||||
isActive = false;
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [selectedProject?.name, refreshKey]);
|
||||
}, [selectedProject?.projectId, refreshKey]);
|
||||
|
||||
return {
|
||||
files,
|
||||
|
||||
@@ -126,7 +126,7 @@ export function useFileTreeOperations({
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.renameFile(selectedProject.name, {
|
||||
const response = await api.renameFile(selectedProject.projectId, {
|
||||
oldPath: renamingItem.path,
|
||||
newName: renameValue,
|
||||
});
|
||||
@@ -161,7 +161,7 @@ export function useFileTreeOperations({
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.deleteFile(selectedProject.name, {
|
||||
const response = await api.deleteFile(selectedProject.projectId, {
|
||||
path: item.path,
|
||||
type: item.type,
|
||||
});
|
||||
@@ -212,7 +212,7 @@ export function useFileTreeOperations({
|
||||
|
||||
setOperationLoading(true);
|
||||
try {
|
||||
const response = await api.createFile(selectedProject.name, {
|
||||
const response = await api.createFile(selectedProject.projectId, {
|
||||
path: newItemParent,
|
||||
type: newItemType,
|
||||
name: newItemName,
|
||||
@@ -287,7 +287,7 @@ export function useFileTreeOperations({
|
||||
if (!selectedProject) return;
|
||||
|
||||
// Use the binary streaming endpoint so downloads preserve raw bytes.
|
||||
const response = await api.readFileBlob(selectedProject.name, item.path);
|
||||
const response = await api.readFileBlob(selectedProject.projectId, item.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download file');
|
||||
@@ -308,7 +308,7 @@ export function useFileTreeOperations({
|
||||
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
|
||||
|
||||
if (node.type === 'file') {
|
||||
const response = await api.readFileBlob(selectedProject.name, node.path);
|
||||
const response = await api.readFileBlob(selectedProject.projectId, node.path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download "${node.name}" for ZIP export`);
|
||||
}
|
||||
|
||||
@@ -154,7 +154,8 @@ export const useFileTreeUpload = ({
|
||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||
|
||||
const response = await api.post(
|
||||
`/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,
|
||||
// File upload endpoint is keyed by DB projectId post-migration.
|
||||
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
||||
formData
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ export interface FileTreeImageSelection {
|
||||
name: string;
|
||||
path: string;
|
||||
projectPath?: string;
|
||||
projectName: string;
|
||||
// DB projectId; used by ImageViewer to build the raw content URL.
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface FileIconData {
|
||||
|
||||
@@ -101,7 +101,9 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name,
|
||||
// Image URL uses the DB projectId so ImageViewer can hit the
|
||||
// /api/projects/:projectId/files/content endpoint directly.
|
||||
projectId: selectedProject.projectId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ type ImageViewerProps = {
|
||||
};
|
||||
|
||||
export default function ImageViewer({ file, onClose }: ImageViewerProps) {
|
||||
const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const imagePath = `/api/projects/${file.projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -64,10 +64,12 @@ export function useGitPanelController({
|
||||
const [operationError, setOperationError] = useState<string | null>(null);
|
||||
|
||||
const clearOperationError = useCallback(() => setOperationError(null), []);
|
||||
const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null);
|
||||
// Tracks the DB projectId so async requests can detect stale responses when
|
||||
// the user switches projects mid-flight.
|
||||
const selectedProjectIdRef = useRef<string | null>(selectedProject?.projectId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectNameRef.current = selectedProject?.name ?? null;
|
||||
selectedProjectIdRef.current = selectedProject?.projectId ?? null;
|
||||
}, [selectedProject]);
|
||||
|
||||
const provider = useSelectedProvider();
|
||||
@@ -78,18 +80,19 @@ export function useGitPanelController({
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = selectedProject.name;
|
||||
// Git endpoints receive the DB projectId via the `project` query param.
|
||||
const projectId = selectedProject.projectId;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`,
|
||||
`/api/git/diff?project=${encodeURIComponent(projectId)}&file=${encodeURIComponent(filePath)}`,
|
||||
{ signal },
|
||||
);
|
||||
const data = await readJson<GitDiffResponse>(response, signal);
|
||||
|
||||
if (
|
||||
signal?.aborted ||
|
||||
selectedProjectNameRef.current !== projectName
|
||||
selectedProjectIdRef.current !== projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -116,16 +119,17 @@ export function useGitPanelController({
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = selectedProject.name;
|
||||
// `project` query param carries the DB projectId everywhere now.
|
||||
const projectId = selectedProject.projectId;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal });
|
||||
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectId)}`, { signal });
|
||||
const data = await readJson<GitStatusResponse>(response, signal);
|
||||
|
||||
if (
|
||||
signal?.aborted ||
|
||||
selectedProjectNameRef.current !== projectName
|
||||
selectedProjectIdRef.current !== projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -150,7 +154,7 @@ export function useGitPanelController({
|
||||
}
|
||||
|
||||
if (
|
||||
selectedProjectNameRef.current !== projectName
|
||||
selectedProjectIdRef.current !== projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -169,7 +173,7 @@ export function useGitPanelController({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.projectId)}`);
|
||||
const data = await readJson<GitBranchesResponse>(response);
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
@@ -196,7 +200,7 @@ export function useGitPanelController({
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.projectId)}`);
|
||||
const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);
|
||||
|
||||
if (!data.error) {
|
||||
@@ -222,7 +226,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
branch: branchName,
|
||||
}),
|
||||
});
|
||||
@@ -257,7 +261,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
branch: trimmedBranchName,
|
||||
}),
|
||||
});
|
||||
@@ -290,7 +294,7 @@ export function useGitPanelController({
|
||||
const response = await fetchWithAuth('/api/git/delete-branch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project: selectedProject.name, branch: branchName }),
|
||||
body: JSON.stringify({ project: selectedProject.projectId, branch: branchName }),
|
||||
});
|
||||
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
@@ -320,7 +324,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -351,7 +355,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -381,7 +385,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -411,7 +415,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
branch: currentBranch,
|
||||
}),
|
||||
});
|
||||
@@ -442,7 +446,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
file: filePath,
|
||||
}),
|
||||
});
|
||||
@@ -472,7 +476,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
file: filePath,
|
||||
}),
|
||||
});
|
||||
@@ -498,7 +502,7 @@ export function useGitPanelController({
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,
|
||||
`/api/git/commits?project=${encodeURIComponent(selectedProject.projectId)}&limit=${RECENT_COMMITS_LIMIT}`,
|
||||
);
|
||||
const data = await readJson<GitCommitsResponse>(response);
|
||||
|
||||
@@ -518,7 +522,7 @@ export function useGitPanelController({
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,
|
||||
`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.projectId)}&commit=${commitHash}`,
|
||||
);
|
||||
const data = await readJson<GitDiffResponse>(response);
|
||||
|
||||
@@ -546,7 +550,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
files,
|
||||
provider,
|
||||
}),
|
||||
@@ -578,7 +582,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
message,
|
||||
files,
|
||||
}),
|
||||
@@ -612,7 +616,7 @@ export function useGitPanelController({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
project: selectedProject.projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -645,7 +649,7 @@ export function useGitPanelController({
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(
|
||||
`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,
|
||||
`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.projectId)}&file=${encodeURIComponent(filePath)}`,
|
||||
);
|
||||
const data = await readJson<GitFileWithDiffResponse>(response);
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { GitOperationResponse } from '../types/types';
|
||||
|
||||
type UseRevertLocalCommitOptions = {
|
||||
projectName: string | null;
|
||||
// DB primary key for the project; forwarded to the git API via the
|
||||
// `project` body param.
|
||||
projectId: string | null;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -11,11 +13,11 @@ async function readJson<T>(response: Response): Promise<T> {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
|
||||
export function useRevertLocalCommit({ projectId, onSuccess }: UseRevertLocalCommitOptions) {
|
||||
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
|
||||
|
||||
const revertLatestLocalCommit = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,7 +26,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC
|
||||
const response = await authenticatedFetch('/api/git/revert-local-commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project: projectName }),
|
||||
body: JSON.stringify({ project: projectId }),
|
||||
});
|
||||
const data = await readJson<GitOperationResponse>(response);
|
||||
|
||||
@@ -39,7 +41,7 @@ export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalC
|
||||
} finally {
|
||||
setIsRevertingLocalCommit(false);
|
||||
}
|
||||
}, [onSuccess, projectName]);
|
||||
}, [onSuccess, projectId]);
|
||||
|
||||
return {
|
||||
isRevertingLocalCommit,
|
||||
|
||||
@@ -58,7 +58,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
});
|
||||
|
||||
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
|
||||
projectName: selectedProject?.name ?? null,
|
||||
// `projectId` (DB primary key) is forwarded to the revert API which uses it
|
||||
// as the `project` body param.
|
||||
projectId: selectedProject?.projectId ?? null,
|
||||
onSuccess: refreshAll,
|
||||
});
|
||||
|
||||
|
||||
@@ -73,13 +73,15 @@ function MainContent({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const selectedProjectName = selectedProject?.name;
|
||||
const currentProjectName = currentProject?.name;
|
||||
// Identify projects by DB `projectId`; the TaskMaster context uses the
|
||||
// same identifier to key its internal maps.
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
const currentProjectId = currentProject?.projectId;
|
||||
|
||||
if (selectedProject && selectedProjectName !== currentProjectName) {
|
||||
if (selectedProject && selectedProjectId !== currentProjectId) {
|
||||
setCurrentProject?.(selectedProject);
|
||||
}
|
||||
}, [selectedProject, currentProject?.name, setCurrentProject]);
|
||||
}, [selectedProject, currentProject?.projectId, setCurrentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowTasksTab && activeTab === 'tasks') {
|
||||
|
||||
@@ -128,7 +128,8 @@ export function useMcpServerForm({
|
||||
currentProjects
|
||||
.map((project) => ({
|
||||
value: getProjectPath(project),
|
||||
label: project.displayName || project.name,
|
||||
// Fall back to projectId (DB primary key) when no display name is set.
|
||||
label: project.displayName || project.projectId,
|
||||
}))
|
||||
.filter((project) => project.value)
|
||||
), [currentProjects]);
|
||||
|
||||
@@ -31,6 +31,8 @@ type GlobalMcpServerResponse = {
|
||||
results: GlobalMcpServerResult[];
|
||||
};
|
||||
|
||||
// Internal MCP-side shape; `name` is now filled from the DB projectId since
|
||||
// the legacy Project.name field was removed during the projectId migration.
|
||||
type ProjectTarget = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -111,6 +113,9 @@ const normalizeServer = (
|
||||
bearerTokenEnvVar: server.bearerTokenEnvVar,
|
||||
envHttpHeaders: server.envHttpHeaders ?? {},
|
||||
workspacePath: project?.path || server.workspacePath,
|
||||
// Keep the `projectName` key in the MCP wire payload for backwards
|
||||
// compatibility. ProjectTarget.name is populated from the DB `projectId`
|
||||
// (see createProjectTargets) so this still carries the new identifier.
|
||||
projectName: project?.name || server.projectName,
|
||||
projectDisplayName: project?.displayName || server.projectDisplayName,
|
||||
};
|
||||
@@ -126,8 +131,9 @@ const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
|
||||
|
||||
seen.add(projectPath);
|
||||
acc.push({
|
||||
name: project.name,
|
||||
displayName: project.displayName || project.name,
|
||||
// Use projectId as the stable internal identifier.
|
||||
name: project.projectId,
|
||||
displayName: project.displayName || project.projectId,
|
||||
path: projectPath,
|
||||
});
|
||||
return acc;
|
||||
|
||||
@@ -7,8 +7,10 @@ export type McpImportMode = 'form' | 'json';
|
||||
export type McpFormMode = 'provider' | 'global';
|
||||
export type KeyValueMap = Record<string, string>;
|
||||
|
||||
// Internal MCP shape; `projectId` replaces the legacy `name` field from the
|
||||
// projectName → projectId migration.
|
||||
export type McpProject = {
|
||||
name: string;
|
||||
projectId: string;
|
||||
displayName?: string;
|
||||
fullPath?: string;
|
||||
path?: string;
|
||||
|
||||
@@ -12,6 +12,9 @@ type PluginTabContentProps = {
|
||||
|
||||
type PluginContext = {
|
||||
theme: 'dark' | 'light';
|
||||
// Plugin contract historically used `name` for the project identifier; we
|
||||
// keep that key and populate it from the DB `projectId` so external plugins
|
||||
// continue to receive a stable opaque id.
|
||||
project: { name: string; path: string } | null;
|
||||
session: { id: string; title: string } | null;
|
||||
};
|
||||
@@ -25,7 +28,7 @@ function buildContext(
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
project: selectedProject
|
||||
? {
|
||||
name: selectedProject.name,
|
||||
name: selectedProject.projectId,
|
||||
path: selectedProject.fullPath || selectedProject.path || '',
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -39,14 +39,16 @@ export default function PRDEditor({
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// PRD hooks are now addressed by DB `projectId`; the backend resolves the
|
||||
// `.taskmaster/docs` folder from the `projects` table.
|
||||
const { existingPrds, refreshExistingPrds } = usePrdRegistry({
|
||||
projectName: project?.name,
|
||||
projectId: project?.projectId,
|
||||
});
|
||||
|
||||
const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]);
|
||||
|
||||
const { savePrd, saving, saveSuccess } = usePrdSave({
|
||||
projectName: project?.name,
|
||||
projectId: project?.projectId,
|
||||
existingPrds,
|
||||
isExistingFile,
|
||||
onAfterSave: async () => {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function usePrdDocument({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file?.projectName || !file?.path) {
|
||||
if (!file?.projectId || !file?.path) {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +87,8 @@ export function usePrdDocument({
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
// readFile uses the DB projectId to resolve the project's path server-side.
|
||||
const response = await api.readFile(file.projectId, file.path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { api } from '../../../utils/api';
|
||||
import type { ExistingPrdFile, PrdListResponse } from '../types';
|
||||
|
||||
type UsePrdRegistryArgs = {
|
||||
projectName?: string;
|
||||
// DB primary key of the project (post migration).
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
type UsePrdRegistryResult = {
|
||||
@@ -15,17 +16,17 @@ function getPrdFiles(data: PrdListResponse): ExistingPrdFile[] {
|
||||
return data.prdFiles || data.prds || [];
|
||||
}
|
||||
|
||||
export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegistryResult {
|
||||
export function usePrdRegistry({ projectId }: UsePrdRegistryArgs): UsePrdRegistryResult {
|
||||
const [existingPrds, setExistingPrds] = useState<ExistingPrdFile[]>([]);
|
||||
|
||||
const refreshExistingPrds = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
setExistingPrds([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectId)}`);
|
||||
if (!response.ok) {
|
||||
setExistingPrds([]);
|
||||
return;
|
||||
@@ -37,7 +38,7 @@ export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegis
|
||||
console.error('Failed to fetch existing PRDs:', error);
|
||||
setExistingPrds([]);
|
||||
}
|
||||
}, [projectName]);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshExistingPrds();
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { ExistingPrdFile, SavePrdInput, SavePrdResult } from '../types';
|
||||
import { ensurePrdExtension } from '../utils/fileName';
|
||||
|
||||
type UsePrdSaveArgs = {
|
||||
projectName?: string;
|
||||
// DB primary key of the project (post migration).
|
||||
projectId?: string;
|
||||
existingPrds: ExistingPrdFile[];
|
||||
isExistingFile: boolean;
|
||||
onAfterSave?: () => Promise<void>;
|
||||
@@ -17,7 +18,7 @@ type UsePrdSaveResult = {
|
||||
};
|
||||
|
||||
export function usePrdSave({
|
||||
projectName,
|
||||
projectId,
|
||||
existingPrds,
|
||||
isExistingFile,
|
||||
onAfterSave,
|
||||
@@ -44,7 +45,7 @@ export function usePrdSave({
|
||||
return { status: 'failed', message: 'Please provide a filename for the PRD.' };
|
||||
}
|
||||
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
return { status: 'failed', message: 'No project selected. Please reopen the editor.' };
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ export function usePrdSave({
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectName)}`, {
|
||||
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectId)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: finalFileName,
|
||||
@@ -100,7 +101,7 @@ export function usePrdSave({
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[existingPrds, isExistingFile, onAfterSave, projectName],
|
||||
[existingPrds, isExistingFile, onAfterSave, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type PrdFile = {
|
||||
name?: string;
|
||||
path?: string;
|
||||
projectName?: string;
|
||||
// DB projectId used to resolve the project path when fetching file content.
|
||||
projectId?: string;
|
||||
content?: string;
|
||||
isExisting?: boolean;
|
||||
};
|
||||
|
||||
@@ -4,13 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import ErrorBanner from './components/ErrorBanner';
|
||||
import StepConfiguration from './components/StepConfiguration';
|
||||
import StepReview from './components/StepReview';
|
||||
import StepTypeSelection from './components/StepTypeSelection';
|
||||
import WizardFooter from './components/WizardFooter';
|
||||
import WizardProgress from './components/WizardProgress';
|
||||
import { useGithubTokens } from './hooks/useGithubTokens';
|
||||
import { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/workspaceApi';
|
||||
import { cloneWorkspaceWithProgress, createProjectRequest } from './data/workspaceApi';
|
||||
import { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils';
|
||||
import type { TokenMode, WizardFormState, WizardStep, WorkspaceType } from './types';
|
||||
import type { TokenMode, WizardFormState, WizardStep } from './types';
|
||||
|
||||
type ProjectCreationWizardProps = {
|
||||
onClose: () => void;
|
||||
@@ -18,7 +17,6 @@ type ProjectCreationWizardProps = {
|
||||
};
|
||||
|
||||
const initialFormState: WizardFormState = {
|
||||
workspaceType: 'existing',
|
||||
workspacePath: '',
|
||||
githubUrl: '',
|
||||
tokenMode: 'stored',
|
||||
@@ -38,7 +36,7 @@ export default function ProjectCreationWizard({
|
||||
const [cloneProgress, setCloneProgress] = useState('');
|
||||
|
||||
const shouldLoadTokens =
|
||||
step === 2 && shouldShowGithubAuthentication(formState.workspaceType, formState.githubUrl);
|
||||
step === 1 && shouldShowGithubAuthentication(formState.githubUrl);
|
||||
|
||||
const autoSelectToken = useCallback((tokenId: string) => {
|
||||
setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId }));
|
||||
@@ -60,11 +58,6 @@ export default function ProjectCreationWizard({
|
||||
setFormState((previous) => ({ ...previous, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const updateWorkspaceType = useCallback(
|
||||
(workspaceType: WorkspaceType) => updateField('workspaceType', workspaceType),
|
||||
[updateField],
|
||||
);
|
||||
|
||||
const updateTokenMode = useCallback(
|
||||
(tokenMode: TokenMode) => updateField('tokenMode', tokenMode),
|
||||
[updateField],
|
||||
@@ -74,22 +67,13 @@ export default function ProjectCreationWizard({
|
||||
setError(null);
|
||||
|
||||
if (step === 1) {
|
||||
if (!formState.workspaceType) {
|
||||
setError(t('projectWizard.errors.selectType'));
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
if (!formState.workspacePath.trim()) {
|
||||
setError(t('projectWizard.errors.providePath'));
|
||||
return;
|
||||
}
|
||||
setStep(3);
|
||||
setStep(2);
|
||||
}
|
||||
}, [formState.workspacePath, formState.workspaceType, step, t]);
|
||||
}, [formState.workspacePath, step, t]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -102,7 +86,7 @@ export default function ProjectCreationWizard({
|
||||
setCloneProgress('');
|
||||
|
||||
try {
|
||||
const shouldCloneRepository = isCloneWorkflow(formState.workspaceType, formState.githubUrl);
|
||||
const shouldCloneRepository = isCloneWorkflow(formState.githubUrl);
|
||||
|
||||
if (shouldCloneRepository) {
|
||||
const project = await cloneWorkspaceWithProgress(
|
||||
@@ -123,8 +107,7 @@ export default function ProjectCreationWizard({
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await createWorkspaceRequest({
|
||||
workspaceType: formState.workspaceType,
|
||||
const project = await createProjectRequest({
|
||||
path: formState.workspacePath.trim(),
|
||||
});
|
||||
|
||||
@@ -142,8 +125,8 @@ export default function ProjectCreationWizard({
|
||||
}, [formState, onClose, onProjectCreated, t]);
|
||||
|
||||
const shouldCloneRepository = useMemo(
|
||||
() => isCloneWorkflow(formState.workspaceType, formState.githubUrl),
|
||||
[formState.githubUrl, formState.workspaceType],
|
||||
() => isCloneWorkflow(formState.githubUrl),
|
||||
[formState.githubUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -173,15 +156,7 @@ export default function ProjectCreationWizard({
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{step === 1 && (
|
||||
<StepTypeSelection
|
||||
workspaceType={formState.workspaceType}
|
||||
onWorkspaceTypeChange={updateWorkspaceType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<StepConfiguration
|
||||
workspaceType={formState.workspaceType}
|
||||
workspacePath={formState.workspacePath}
|
||||
githubUrl={formState.githubUrl}
|
||||
tokenMode={formState.tokenMode}
|
||||
@@ -200,11 +175,11 @@ export default function ProjectCreationWizard({
|
||||
onNewGithubTokenChange={(newGithubToken) =>
|
||||
updateField('newGithubToken', newGithubToken)
|
||||
}
|
||||
onAdvanceToConfirm={() => setStep(3)}
|
||||
onAdvanceToConfirm={() => setStep(2)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{step === 2 && (
|
||||
<StepReview
|
||||
formState={formState}
|
||||
selectedTokenName={selectedTokenName}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '../../../shared/view/ui';
|
||||
import { shouldShowGithubAuthentication } from '../utils/pathUtils';
|
||||
import type { GithubTokenCredential, TokenMode, WorkspaceType } from '../types';
|
||||
import type { GithubTokenCredential, TokenMode } from '../types';
|
||||
import GithubAuthenticationCard from './GithubAuthenticationCard';
|
||||
import WorkspacePathField from './WorkspacePathField';
|
||||
|
||||
type StepConfigurationProps = {
|
||||
workspaceType: WorkspaceType;
|
||||
workspacePath: string;
|
||||
githubUrl: string;
|
||||
tokenMode: TokenMode;
|
||||
@@ -25,7 +24,6 @@ type StepConfigurationProps = {
|
||||
};
|
||||
|
||||
export default function StepConfiguration({
|
||||
workspaceType,
|
||||
workspacePath,
|
||||
githubUrl,
|
||||
tokenMode,
|
||||
@@ -43,19 +41,16 @@ export default function StepConfiguration({
|
||||
onAdvanceToConfirm,
|
||||
}: StepConfigurationProps) {
|
||||
const { t } = useTranslation();
|
||||
const showGithubAuth = shouldShowGithubAuthentication(workspaceType, githubUrl);
|
||||
const showGithubAuth = shouldShowGithubAuthentication(githubUrl);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step2.existingPath')
|
||||
: t('projectWizard.step2.newPath')}
|
||||
{t('projectWizard.step2.newPath')}
|
||||
</label>
|
||||
|
||||
<WorkspacePathField
|
||||
workspaceType={workspaceType}
|
||||
value={workspacePath}
|
||||
disabled={isCreating}
|
||||
onChange={onWorkspacePathChange}
|
||||
@@ -63,45 +58,39 @@ export default function StepConfiguration({
|
||||
/>
|
||||
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{workspaceType === 'existing'
|
||||
? t('projectWizard.step2.existingHelp')
|
||||
: t('projectWizard.step2.newHelp')}
|
||||
{t('projectWizard.step2.newHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{workspaceType === 'new' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('projectWizard.step2.githubUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(event) => onGithubUrlChange(event.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.githubHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('projectWizard.step2.githubUrl')}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(event) => onGithubUrlChange(event.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projectWizard.step2.githubHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showGithubAuth && (
|
||||
<GithubAuthenticationCard
|
||||
tokenMode={tokenMode}
|
||||
selectedGithubToken={selectedGithubToken}
|
||||
newGithubToken={newGithubToken}
|
||||
availableTokens={availableTokens}
|
||||
loadingTokens={loadingTokens}
|
||||
tokenLoadError={tokenLoadError}
|
||||
onTokenModeChange={onTokenModeChange}
|
||||
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
|
||||
onNewGithubTokenChange={onNewGithubTokenChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{showGithubAuth && (
|
||||
<GithubAuthenticationCard
|
||||
tokenMode={tokenMode}
|
||||
selectedGithubToken={selectedGithubToken}
|
||||
newGithubToken={newGithubToken}
|
||||
availableTokens={availableTokens}
|
||||
loadingTokens={loadingTokens}
|
||||
tokenLoadError={tokenLoadError}
|
||||
onTokenModeChange={onTokenModeChange}
|
||||
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
|
||||
onNewGithubTokenChange={onNewGithubTokenChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,17 +42,6 @@ export default function StepReview({
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step3.workspaceType')}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{formState.workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingWorkspace')
|
||||
: t('projectWizard.step3.newWorkspace')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
|
||||
<span className="break-all font-mono text-xs text-gray-900 dark:text-white">
|
||||
@@ -60,7 +49,7 @@ export default function StepReview({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{formState.workspaceType === 'new' && formState.githubUrl && (
|
||||
{formState.githubUrl && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
@@ -94,11 +83,9 @@ export default function StepReview({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{formState.workspaceType === 'existing'
|
||||
? t('projectWizard.step3.existingInfo')
|
||||
: formState.githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
{formState.githubUrl
|
||||
? t('projectWizard.step3.newWithClone')
|
||||
: t('projectWizard.step3.newEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { FolderPlus, GitBranch } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { WorkspaceType } from '../types';
|
||||
|
||||
type StepTypeSelectionProps = {
|
||||
workspaceType: WorkspaceType;
|
||||
onWorkspaceTypeChange: (workspaceType: WorkspaceType) => void;
|
||||
};
|
||||
|
||||
export default function StepTypeSelection({
|
||||
workspaceType,
|
||||
onWorkspaceTypeChange,
|
||||
}: StepTypeSelectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('projectWizard.step1.question')}
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<button
|
||||
onClick={() => onWorkspaceTypeChange('existing')}
|
||||
className={`rounded-lg border-2 p-4 text-left transition-all ${
|
||||
workspaceType === 'existing'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/50">
|
||||
<FolderPlus className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
|
||||
{t('projectWizard.step1.existing.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.existing.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onWorkspaceTypeChange('new')}
|
||||
className={`rounded-lg border-2 p-4 text-left transition-all ${
|
||||
workspaceType === 'new'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/50">
|
||||
<GitBranch className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
|
||||
{t('projectWizard.step1.new.title')}
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('projectWizard.step1.new.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default function WizardFooter({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onClick={step === 3 ? onCreate : onNext} disabled={isCreating}>
|
||||
<Button onClick={step === 2 ? onCreate : onNext} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -45,7 +45,7 @@ export default function WizardFooter({
|
||||
? t('projectWizard.buttons.cloning', { defaultValue: 'Cloning...' })
|
||||
: t('projectWizard.buttons.creating')}
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
) : step === 2 ? (
|
||||
<>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
{t('projectWizard.buttons.createProject')}
|
||||
|
||||
@@ -9,7 +9,7 @@ type WizardProgressProps = {
|
||||
|
||||
export default function WizardProgress({ step }: WizardProgressProps) {
|
||||
const { t } = useTranslation();
|
||||
const steps: WizardStep[] = [1, 2, 3];
|
||||
const steps: WizardStep[] = [1, 2];
|
||||
|
||||
return (
|
||||
<div className="px-6 pb-2 pt-4">
|
||||
@@ -30,14 +30,12 @@ export default function WizardProgress({ step }: WizardProgressProps) {
|
||||
</div>
|
||||
<span className="hidden text-sm font-medium text-gray-700 dark:text-gray-300 sm:inline">
|
||||
{currentStep === 1
|
||||
? t('projectWizard.steps.type')
|
||||
: currentStep === 2
|
||||
? t('projectWizard.steps.configure')
|
||||
: t('projectWizard.steps.confirm')}
|
||||
? t('projectWizard.steps.configure')
|
||||
: t('projectWizard.steps.confirm')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentStep < 3 && (
|
||||
{currentStep < 2 && (
|
||||
<div
|
||||
className={`mx-2 h-1 flex-1 rounded ${
|
||||
currentStep < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
|
||||
@@ -3,11 +3,10 @@ import { FolderOpen } from 'lucide-react';
|
||||
import { Button, Input } from '../../../shared/view/ui';
|
||||
import { browseFilesystemFolders } from '../data/workspaceApi';
|
||||
import { getSuggestionRootPath } from '../utils/pathUtils';
|
||||
import type { FolderSuggestion, WorkspaceType } from '../types';
|
||||
import type { FolderSuggestion } from '../types';
|
||||
import FolderBrowserModal from './FolderBrowserModal';
|
||||
|
||||
type WorkspacePathFieldProps = {
|
||||
workspaceType: WorkspaceType;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (path: string) => void;
|
||||
@@ -15,7 +14,6 @@ type WorkspacePathFieldProps = {
|
||||
};
|
||||
|
||||
export default function WorkspacePathField({
|
||||
workspaceType,
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
@@ -88,11 +86,7 @@ export default function WorkspacePathField({
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={
|
||||
workspaceType === 'existing'
|
||||
? '/path/to/existing/workspace'
|
||||
: '/path/to/new/workspace'
|
||||
}
|
||||
placeholder="/path/to/project/workspace"
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -127,7 +121,7 @@ export default function WorkspacePathField({
|
||||
|
||||
<FolderBrowserModal
|
||||
isOpen={showFolderBrowser}
|
||||
autoAdvanceOnSelect={workspaceType === 'existing'}
|
||||
autoAdvanceOnSelect={false}
|
||||
onClose={() => setShowFolderBrowser(false)}
|
||||
onFolderSelected={handleFolderSelected}
|
||||
/>
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
BrowseFilesystemResponse,
|
||||
CloneProgressEvent,
|
||||
CreateFolderResponse,
|
||||
CreateWorkspacePayload,
|
||||
CreateWorkspaceResponse,
|
||||
CreateProjectPayload,
|
||||
CreateProjectResponse,
|
||||
CredentialsResponse,
|
||||
FolderSuggestion,
|
||||
TokenMode,
|
||||
@@ -27,6 +27,42 @@ const parseJson = async <T>(response: Response): Promise<T> => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const resolveCreateProjectErrorMessage = (responseData: CreateProjectResponse): string | null => {
|
||||
if (typeof responseData.details === 'string' && responseData.details.trim().length > 0) {
|
||||
return responseData.details;
|
||||
}
|
||||
|
||||
if (typeof responseData.error === 'string' && responseData.error.trim().length > 0) {
|
||||
return responseData.error;
|
||||
}
|
||||
|
||||
if (responseData.error && typeof responseData.error === 'object') {
|
||||
const errorObject = responseData.error as { message?: unknown; details?: unknown };
|
||||
|
||||
if (typeof errorObject.details === 'string' && errorObject.details.trim().length > 0) {
|
||||
return errorObject.details;
|
||||
}
|
||||
|
||||
if (typeof errorObject.message === 'string' && errorObject.message.trim().length > 0) {
|
||||
return errorObject.message;
|
||||
}
|
||||
|
||||
if (
|
||||
errorObject.details
|
||||
&& typeof errorObject.details === 'object'
|
||||
&& typeof (errorObject.details as { projectPath?: unknown }).projectPath === 'string'
|
||||
) {
|
||||
return `Project path already exists: ${(errorObject.details as { projectPath: string }).projectPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof responseData.message === 'string' && responseData.message.trim().length > 0) {
|
||||
return responseData.message;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const fetchGithubTokenCredentials = async () => {
|
||||
const response = await api.get('/settings/credentials?type=github_token');
|
||||
const data = await parseJson<CredentialsResponse>(response);
|
||||
@@ -64,12 +100,12 @@ export const createFolderInFilesystem = async (folderPath: string) => {
|
||||
return data.path || folderPath;
|
||||
};
|
||||
|
||||
export const createWorkspaceRequest = async (payload: CreateWorkspacePayload) => {
|
||||
const response = await api.createWorkspace(payload);
|
||||
const data = await parseJson<CreateWorkspaceResponse>(response);
|
||||
export const createProjectRequest = async (payload: CreateProjectPayload) => {
|
||||
const response = await api.createProject(payload);
|
||||
const data = await parseJson<CreateProjectResponse>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.details || data.error || 'Failed to create workspace');
|
||||
throw new Error(resolveCreateProjectErrorMessage(data) || 'Failed to create project');
|
||||
}
|
||||
|
||||
return data.project;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export type WizardStep = 1 | 2 | 3;
|
||||
|
||||
export type WorkspaceType = 'existing' | 'new';
|
||||
export type WizardStep = 1 | 2;
|
||||
|
||||
export type TokenMode = 'stored' | 'new' | 'none';
|
||||
|
||||
@@ -34,16 +32,23 @@ export type CreateFolderResponse = {
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export type CreateWorkspacePayload = {
|
||||
workspaceType: WorkspaceType;
|
||||
export type CreateProjectPayload = {
|
||||
path: string;
|
||||
customName?: string;
|
||||
};
|
||||
|
||||
export type CreateWorkspaceResponse = {
|
||||
export type CreateProjectApiError = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
export type CreateProjectResponse = {
|
||||
success?: boolean;
|
||||
project?: Record<string, unknown>;
|
||||
error?: string;
|
||||
error?: string | CreateProjectApiError;
|
||||
details?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type CloneProgressEvent = {
|
||||
@@ -53,7 +58,6 @@ export type CloneProgressEvent = {
|
||||
};
|
||||
|
||||
export type WizardFormState = {
|
||||
workspaceType: WorkspaceType;
|
||||
workspacePath: string;
|
||||
githubUrl: string;
|
||||
tokenMode: TokenMode;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { WorkspaceType } from '../types';
|
||||
|
||||
const SSH_PREFIXES = ['git@', 'ssh://'];
|
||||
const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\?$/;
|
||||
|
||||
@@ -8,13 +6,11 @@ export const isSshGitUrl = (url: string): boolean => {
|
||||
return SSH_PREFIXES.some((prefix) => trimmedUrl.startsWith(prefix));
|
||||
};
|
||||
|
||||
export const shouldShowGithubAuthentication = (
|
||||
workspaceType: WorkspaceType,
|
||||
githubUrl: string,
|
||||
): boolean => workspaceType === 'new' && githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
|
||||
export const shouldShowGithubAuthentication = (githubUrl: string): boolean =>
|
||||
githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
|
||||
|
||||
export const isCloneWorkflow = (workspaceType: WorkspaceType, githubUrl: string): boolean =>
|
||||
workspaceType === 'new' && githubUrl.trim().length > 0;
|
||||
export const isCloneWorkflow = (githubUrl: string): boolean =>
|
||||
githubUrl.trim().length > 0;
|
||||
|
||||
export const getSuggestionRootPath = (inputPath: string): string => {
|
||||
const trimmedPath = inputPath.trim();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentCategoryContentSectionProps } from '../types';
|
||||
import type { McpProject } from '../../../../../mcp/types';
|
||||
import { McpServers } from '../../../../../mcp';
|
||||
|
||||
import AccountContent from './content/AccountContent';
|
||||
@@ -71,9 +72,16 @@ export default function AgentCategoryContentSection({
|
||||
)}
|
||||
|
||||
{selectedCategory === 'mcp' && (
|
||||
// SettingsProject.name is populated from the DB projectId by
|
||||
// normalizeProjectForSettings, so we can map it straight through.
|
||||
<McpServers
|
||||
selectedProvider={selectedAgent}
|
||||
currentProjects={projects}
|
||||
currentProjects={projects.map<McpProject>((project) => ({
|
||||
projectId: project.name,
|
||||
displayName: project.displayName,
|
||||
fullPath: project.fullPath,
|
||||
path: project.path,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
|
||||
import type {
|
||||
AdditionalSessionsByProject,
|
||||
DeleteProjectConfirmation,
|
||||
LoadingSessionsByProject,
|
||||
ProjectSortOrder,
|
||||
SessionDeleteConfirmation,
|
||||
SessionWithProvider,
|
||||
} from '../types/types';
|
||||
import {
|
||||
clearLegacyStarredProjectIds,
|
||||
filterProjects,
|
||||
getAllSessions,
|
||||
loadStarredProjects,
|
||||
persistStarredProjects,
|
||||
readLegacyStarredProjectIds,
|
||||
readProjectSortOrder,
|
||||
sortProjects,
|
||||
} from '../utils/utils';
|
||||
@@ -42,6 +40,9 @@ type ConversationSession = {
|
||||
};
|
||||
|
||||
type ConversationProjectResult = {
|
||||
// Emitted by the provider search service so the sidebar can map a
|
||||
// match back to the Project in its current state by projectId.
|
||||
projectId: string | null;
|
||||
projectName: string;
|
||||
projectDisplayName: string;
|
||||
sessions: ConversationSession[];
|
||||
@@ -69,7 +70,9 @@ type UseSidebarControllerArgs = {
|
||||
onProjectSelect: (project: Project) => void;
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onSessionDelete?: (sessionId: string) => void;
|
||||
onProjectDelete?: (projectName: string) => void;
|
||||
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
|
||||
// `projectId` is the DB-assigned identifier; callbacks use that post-migration.
|
||||
onProjectDelete?: (projectId: string) => void;
|
||||
setCurrentProject: (project: Project) => void;
|
||||
setSidebarVisible: (visible: boolean) => void;
|
||||
sidebarVisible: boolean;
|
||||
@@ -78,7 +81,7 @@ type UseSidebarControllerArgs = {
|
||||
export function useSidebarController({
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
selectedSession: _selectedSession,
|
||||
isLoading,
|
||||
isMobile,
|
||||
t,
|
||||
@@ -86,6 +89,7 @@ export function useSidebarController({
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
onLoadMoreSessions,
|
||||
onProjectDelete,
|
||||
setCurrentProject,
|
||||
setSidebarVisible,
|
||||
@@ -95,13 +99,10 @@ export function useSidebarController({
|
||||
const [editingProject, setEditingProject] = useState<string | null>(null);
|
||||
const [showNewProject, setShowNewProject] = useState(false);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [loadingSessions, setLoadingSessions] = useState<LoadingSessionsByProject>({});
|
||||
const [additionalSessions, setAdditionalSessions] = useState<AdditionalSessionsByProject>({});
|
||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [projectHasMoreOverrides, setProjectHasMoreOverrides] = useState<Record<string, boolean>>({});
|
||||
const [editingSession, setEditingSession] = useState<string | null>(null);
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
@@ -109,14 +110,18 @@ export function useSidebarController({
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
|
||||
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState<Map<string, boolean>>(new Map());
|
||||
const [loadingMoreProjects, setLoadingMoreProjects] = useState<Set<string>>(new Set());
|
||||
const searchSeqRef = useRef(0);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const starToggleSequenceByProjectRef = useRef<Map<string, number>>(new Map());
|
||||
const migrationStartedRef = useRef(false);
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
const isSidebarCollapsed = !isMobile && !sidebarVisible;
|
||||
|
||||
@@ -129,30 +134,34 @@ export function useSidebarController({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAdditionalSessions({});
|
||||
setInitialSessionsLoaded(new Set());
|
||||
setProjectHasMoreOverrides({});
|
||||
}, [projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
setExpandedProjects((prev) => {
|
||||
if (prev.has(selectedProject.name)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
next.add(selectedProject.name);
|
||||
return next;
|
||||
});
|
||||
// Auto-expand only when the selected project identity changes.
|
||||
// Depending on the full `selectedProject` object (or `selectedSession`) causes
|
||||
// websocket-driven list refreshes to re-open projects users manually collapsed.
|
||||
const selectedProjectId = selectedProject?.projectId;
|
||||
if (!selectedProjectId) {
|
||||
return;
|
||||
}
|
||||
}, [selectedSession, selectedProject]);
|
||||
|
||||
setExpandedProjects((prev) => {
|
||||
if (prev.has(selectedProjectId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
next.add(selectedProjectId);
|
||||
return next;
|
||||
});
|
||||
}, [selectedProject?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects.length > 0 && !isLoading) {
|
||||
const loadedProjects = new Set<string>();
|
||||
projects.forEach((project) => {
|
||||
if (project.sessions && project.sessions.length >= 0) {
|
||||
loadedProjects.add(project.name);
|
||||
loadedProjects.add(project.projectId);
|
||||
}
|
||||
});
|
||||
setInitialSessionsLoaded(loadedProjects);
|
||||
@@ -186,17 +195,83 @@ export function useSidebarController({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onRefreshRef.current = onRefresh;
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (migrationStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyStarredProjectIds = readLegacyStarredProjectIds();
|
||||
if (legacyStarredProjectIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
migrationStartedRef.current = true;
|
||||
|
||||
const migrateLegacyStars = async () => {
|
||||
try {
|
||||
await api.migrateLegacyProjectStars(legacyStarredProjectIds);
|
||||
await onRefreshRef.current();
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to migrate legacy starred projects:', error);
|
||||
} finally {
|
||||
clearLegacyStarredProjectIds();
|
||||
}
|
||||
};
|
||||
|
||||
void migrateLegacyStars();
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticStarByProjectId((previous) => {
|
||||
if (previous.size === 0) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = new Map(previous);
|
||||
let changed = false;
|
||||
|
||||
for (const [projectId, optimisticValue] of previous.entries()) {
|
||||
const project = projects.find((candidate) => candidate.projectId === projectId);
|
||||
if (!project) {
|
||||
next.delete(projectId);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean(project.isStarred) === optimisticValue) {
|
||||
next.delete(projectId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [projects]);
|
||||
|
||||
// Debounce search text updates so both project filtering and conversation
|
||||
// SSE requests avoid running on every keypress.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedSearchQuery(searchFilter.trim());
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [searchFilter]);
|
||||
|
||||
// Debounced conversation search with SSE streaming
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
const query = searchFilter.trim();
|
||||
const query = debouncedSearchQuery;
|
||||
if (searchMode !== 'conversations' || query.length < 2) {
|
||||
searchSeqRef.current += 1;
|
||||
setConversationResults(null);
|
||||
@@ -208,163 +283,244 @@ export function useSidebarController({
|
||||
setIsSearching(true);
|
||||
const seq = ++searchSeqRef.current;
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
if (seq !== searchSeqRef.current) return;
|
||||
if (seq !== searchSeqRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = api.searchConversationsUrl(query);
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
const url = api.searchConversationsUrl(query);
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
const accumulated: ConversationProjectResult[] = [];
|
||||
let totalMatches = 0;
|
||||
const accumulated: ConversationProjectResult[] = [];
|
||||
let totalMatches = 0;
|
||||
|
||||
es.addEventListener('result', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as {
|
||||
projectResult: ConversationProjectResult;
|
||||
totalMatches: number;
|
||||
scannedProjects: number;
|
||||
totalProjects: number;
|
||||
};
|
||||
accumulated.push(data.projectResult);
|
||||
totalMatches = data.totalMatches;
|
||||
setConversationResults({ results: [...accumulated], totalMatches, query });
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
es.addEventListener('result', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as {
|
||||
projectResult: ConversationProjectResult;
|
||||
totalMatches: number;
|
||||
scannedProjects: number;
|
||||
totalProjects: number;
|
||||
};
|
||||
accumulated.push(data.projectResult);
|
||||
totalMatches = data.totalMatches;
|
||||
setConversationResults({ results: [...accumulated], totalMatches, query });
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('progress', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
|
||||
totalMatches = data.totalMatches;
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
es.addEventListener('progress', (evt) => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
try {
|
||||
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
|
||||
totalMatches = data.totalMatches;
|
||||
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
|
||||
} catch {
|
||||
// Ignore malformed SSE data
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('done', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
es.addEventListener('done', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
}, 400);
|
||||
es.addEventListener('error', () => {
|
||||
if (seq !== searchSeqRef.current) { es.close(); return; }
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setIsSearching(false);
|
||||
setSearchProgress(null);
|
||||
if (accumulated.length === 0) {
|
||||
setConversationResults({ results: [], totalMatches: 0, query });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [searchFilter, searchMode]);
|
||||
}, [debouncedSearchQuery, searchMode]);
|
||||
|
||||
const handleTouchClick = useCallback(
|
||||
(callback: () => void) =>
|
||||
(event: React.TouchEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
callback();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleProject = useCallback((projectName: string) => {
|
||||
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
|
||||
// `projectId` as their identifier after the migration.
|
||||
const toggleProject = useCallback((projectId: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
const next = new Set<string>();
|
||||
if (!prev.has(projectName)) {
|
||||
next.add(projectName);
|
||||
if (!prev.has(projectId)) {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionClick = useCallback(
|
||||
(session: SessionWithProvider, projectName: string) => {
|
||||
onSessionSelect({ ...session, __projectName: projectName });
|
||||
(session: SessionWithProvider, projectId: string) => {
|
||||
// Tag the session with its owning projectId so downstream handlers
|
||||
// can correlate it with the selectedProject in the app state.
|
||||
onSessionSelect({ ...session, __projectId: projectId });
|
||||
},
|
||||
[onSessionSelect],
|
||||
);
|
||||
|
||||
const toggleStarProject = useCallback((projectName: string) => {
|
||||
setStarredProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectName)) {
|
||||
next.delete(projectName);
|
||||
} else {
|
||||
next.add(projectName);
|
||||
const resolveProjectStarState = useCallback(
|
||||
(projectId: string): boolean => {
|
||||
if (optimisticStarByProjectId.has(projectId)) {
|
||||
return Boolean(optimisticStarByProjectId.get(projectId));
|
||||
}
|
||||
|
||||
persistStarredProjects(next);
|
||||
return projects.some((project) => project.projectId === projectId && Boolean(project.isStarred));
|
||||
},
|
||||
[optimisticStarByProjectId, projects],
|
||||
);
|
||||
|
||||
const toggleStarProject = useCallback((projectId: string) => {
|
||||
const previousStarState = resolveProjectStarState(projectId);
|
||||
const optimisticStarState = !previousStarState;
|
||||
const latestSequence = (starToggleSequenceByProjectRef.current.get(projectId) ?? 0) + 1;
|
||||
starToggleSequenceByProjectRef.current.set(projectId, latestSequence);
|
||||
|
||||
setOptimisticStarByProjectId((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(projectId, optimisticStarState);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isProjectStarred = useCallback(
|
||||
(projectName: string) => starredProjects.has(projectName),
|
||||
[starredProjects],
|
||||
);
|
||||
|
||||
const getProjectSessions = useCallback(
|
||||
(project: Project) => getAllSessions(project, additionalSessions),
|
||||
[additionalSessions],
|
||||
);
|
||||
|
||||
const projectsWithSessionMeta = useMemo(
|
||||
() =>
|
||||
projects.map((project) => {
|
||||
const hasMoreOverride = projectHasMoreOverrides[project.name];
|
||||
if (hasMoreOverride === undefined) {
|
||||
return project;
|
||||
const updateStar = async () => {
|
||||
try {
|
||||
const response = await api.toggleProjectStar(projectId);
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json()) as { error?: string | { message?: string } };
|
||||
const errorPayload = payload.error;
|
||||
const message =
|
||||
typeof errorPayload === 'string'
|
||||
? errorPayload
|
||||
: errorPayload && typeof errorPayload === 'object' && errorPayload.message
|
||||
? errorPayload.message
|
||||
: t('messages.updateProjectError');
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
sessionMeta: { ...project.sessionMeta, hasMore: hasMoreOverride },
|
||||
};
|
||||
}),
|
||||
[projectHasMoreOverrides, projects],
|
||||
const payload = (await response.json()) as { isStarred?: boolean };
|
||||
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
|
||||
if (!isLatestSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticStarByProjectId((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(projectId, Boolean(payload.isStarred));
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
|
||||
if (!isLatestSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticStarByProjectId((previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(projectId, previousStarState);
|
||||
return next;
|
||||
});
|
||||
console.error('[Sidebar] Failed to toggle project star:', error);
|
||||
alert(t('messages.updateProjectError'));
|
||||
}
|
||||
};
|
||||
|
||||
void updateStar();
|
||||
}, [resolveProjectStarState, t]);
|
||||
|
||||
const isProjectStarred = useCallback(
|
||||
(projectId: string) => resolveProjectStarState(projectId),
|
||||
[resolveProjectStarState],
|
||||
);
|
||||
|
||||
const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []);
|
||||
|
||||
const loadMoreSessionsForProject = useCallback(async (projectId: string) => {
|
||||
if (!onLoadMoreSessions) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldLoad = false;
|
||||
setLoadingMoreProjects((previous) => {
|
||||
if (previous.has(projectId)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
shouldLoad = true;
|
||||
const next = new Set(previous);
|
||||
next.add(projectId);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!shouldLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onLoadMoreSessions(projectId);
|
||||
} catch (error) {
|
||||
console.error('[Sidebar] Failed to load more sessions:', error);
|
||||
alert(t('messages.refreshError'));
|
||||
} finally {
|
||||
setLoadingMoreProjects((previous) => {
|
||||
const next = new Set(previous);
|
||||
next.delete(projectId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [onLoadMoreSessions, t]);
|
||||
|
||||
const projectsWithResolvedStarState = useMemo(() => {
|
||||
if (optimisticStarByProjectId.size === 0) {
|
||||
return projects;
|
||||
}
|
||||
|
||||
return projects.map((project) => {
|
||||
const optimisticStarState = optimisticStarByProjectId.get(project.projectId);
|
||||
if (optimisticStarState === undefined) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const currentStarState = Boolean(project.isStarred);
|
||||
if (currentStarState === optimisticStarState) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
isStarred: optimisticStarState,
|
||||
};
|
||||
});
|
||||
}, [optimisticStarByProjectId, projects]);
|
||||
|
||||
const sortedProjects = useMemo(
|
||||
() => sortProjects(projectsWithSessionMeta, projectSortOrder, starredProjects, additionalSessions),
|
||||
[additionalSessions, projectSortOrder, projectsWithSessionMeta, starredProjects],
|
||||
() => sortProjects(projectsWithResolvedStarState, projectSortOrder),
|
||||
[projectSortOrder, projectsWithResolvedStarState],
|
||||
);
|
||||
|
||||
const filteredProjects = useMemo(
|
||||
() => filterProjects(sortedProjects, searchFilter),
|
||||
[searchFilter, sortedProjects],
|
||||
() => filterProjects(sortedProjects, debouncedSearchQuery),
|
||||
[debouncedSearchQuery, sortedProjects],
|
||||
);
|
||||
|
||||
const startEditing = useCallback((project: Project) => {
|
||||
setEditingProject(project.name);
|
||||
// `editingProject` is keyed by projectId so it stays stable across
|
||||
// display-name mutations that happen while the input is open.
|
||||
setEditingProject(project.projectId);
|
||||
setEditingName(project.displayName);
|
||||
}, []);
|
||||
|
||||
@@ -374,9 +530,11 @@ export function useSidebarController({
|
||||
}, []);
|
||||
|
||||
const saveProjectName = useCallback(
|
||||
async (projectName: string) => {
|
||||
// `projectId` is the DB primary key; the rename API resolves the path
|
||||
// through the `projects` table before writing the new display name.
|
||||
async (projectId: string) => {
|
||||
try {
|
||||
const response = await api.renameProject(projectName, editingName);
|
||||
const response = await api.renameProject(projectId, editingName);
|
||||
if (response.ok) {
|
||||
if (window.refreshProjects) {
|
||||
await window.refreshProjects();
|
||||
@@ -397,13 +555,15 @@ export function useSidebarController({
|
||||
);
|
||||
|
||||
const showDeleteSessionConfirmation = useCallback(
|
||||
// Kept with project/provider arguments for component wiring compatibility;
|
||||
// deletion now uses only `sessionId` via /api/providers/sessions/:sessionId.
|
||||
(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
sessionTitle: string,
|
||||
provider: SessionDeleteConfirmation['provider'] = 'claude',
|
||||
) => {
|
||||
setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });
|
||||
setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider });
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -413,18 +573,11 @@ export function useSidebarController({
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectName, sessionId, provider } = sessionDeleteConfirmation;
|
||||
const { sessionId } = sessionDeleteConfirmation;
|
||||
setSessionDeleteConfirmation(null);
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (provider === 'codex') {
|
||||
response = await api.deleteCodexSession(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
response = await api.deleteGeminiSession(sessionId);
|
||||
} else {
|
||||
response = await api.deleteSession(projectName, sessionId);
|
||||
}
|
||||
const response = await api.deleteSession(sessionId);
|
||||
|
||||
if (response.ok) {
|
||||
onSessionDelete?.(sessionId);
|
||||
@@ -457,20 +610,24 @@ export function useSidebarController({
|
||||
return;
|
||||
}
|
||||
|
||||
const { project, sessionCount } = deleteConfirmation;
|
||||
const isEmpty = sessionCount === 0;
|
||||
const { project } = deleteConfirmation;
|
||||
|
||||
setDeleteConfirmation(null);
|
||||
setDeletingProjects((prev) => new Set([...prev, project.name]));
|
||||
// Track in-flight deletes by projectId so the UI can disable actions
|
||||
// even if the project object is rebuilt while the request is flying.
|
||||
setDeletingProjects((prev) => new Set([...prev, project.projectId]));
|
||||
|
||||
try {
|
||||
const response = await api.deleteProject(project.name, !isEmpty, deleteData);
|
||||
const response = await api.deleteProject(project.projectId, deleteData);
|
||||
|
||||
if (response.ok) {
|
||||
onProjectDelete?.(project.name);
|
||||
onProjectDelete?.(project.projectId);
|
||||
} else {
|
||||
const error = (await response.json()) as { error?: string };
|
||||
alert(error.error || t('messages.deleteProjectFailed'));
|
||||
const data = (await response.json()) as { error?: string | { message?: string } };
|
||||
const err = data.error;
|
||||
const message =
|
||||
typeof err === 'string' ? err : err && typeof err === 'object' && err.message ? err.message : t('messages.deleteProjectFailed');
|
||||
alert(message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting project:', error);
|
||||
@@ -478,55 +635,12 @@ export function useSidebarController({
|
||||
} finally {
|
||||
setDeletingProjects((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(project.name);
|
||||
next.delete(project.projectId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [deleteConfirmation, onProjectDelete, t]);
|
||||
|
||||
const loadMoreSessions = useCallback(
|
||||
async (project: Project) => {
|
||||
const hasMoreOverride = projectHasMoreOverrides[project.name];
|
||||
const canLoadMore =
|
||||
hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true;
|
||||
if (!canLoadMore || loadingSessions[project.name]) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));
|
||||
|
||||
try {
|
||||
const currentSessionCount =
|
||||
(project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
|
||||
const response = await api.sessions(project.name, 5, currentSessionCount);
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as {
|
||||
sessions?: ProjectSession[];
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
setAdditionalSessions((prev) => ({
|
||||
...prev,
|
||||
[project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],
|
||||
}));
|
||||
|
||||
if (result.hasMore === false) {
|
||||
// Keep hasMore state in local hook state instead of mutating the project prop object.
|
||||
setProjectHasMoreOverrides((prev) => ({ ...prev, [project.name]: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading more sessions:', error);
|
||||
} finally {
|
||||
setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));
|
||||
}
|
||||
},
|
||||
[additionalSessions, loadingSessions, projectHasMoreOverrides],
|
||||
);
|
||||
|
||||
const handleProjectSelect = useCallback(
|
||||
(project: Project) => {
|
||||
onProjectSelect(project);
|
||||
@@ -545,7 +659,9 @@ export function useSidebarController({
|
||||
}, [onRefresh]);
|
||||
|
||||
const updateSessionSummary = useCallback(
|
||||
async (_projectName: string, sessionId: string, summary: string, provider: LLMProvider) => {
|
||||
// `_projectId` and `_provider` are preserved for compatibility with
|
||||
// existing sidebar callback signatures; backend rename only needs sessionId.
|
||||
async (_projectId: string, sessionId: string, summary: string, _provider: LLMProvider) => {
|
||||
const trimmed = summary.trim();
|
||||
if (!trimmed) {
|
||||
setEditingSession(null);
|
||||
@@ -553,7 +669,7 @@ export function useSidebarController({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await api.renameSession(sessionId, trimmed, provider);
|
||||
const response = await api.renameSession(sessionId, trimmed);
|
||||
if (response.ok) {
|
||||
await onRefresh();
|
||||
} else {
|
||||
@@ -585,8 +701,6 @@ export function useSidebarController({
|
||||
editingProject,
|
||||
showNewProject,
|
||||
editingName,
|
||||
loadingSessions,
|
||||
additionalSessions,
|
||||
initialSessionsLoaded,
|
||||
currentTime,
|
||||
projectSortOrder,
|
||||
@@ -595,16 +709,17 @@ export function useSidebarController({
|
||||
editingSessionName,
|
||||
searchFilter,
|
||||
deletingProjects,
|
||||
loadingMoreProjects,
|
||||
deleteConfirmation,
|
||||
sessionDeleteConfirmation,
|
||||
showVersionModal,
|
||||
starredProjects,
|
||||
filteredProjects,
|
||||
toggleProject,
|
||||
handleSessionClick,
|
||||
toggleStarProject,
|
||||
isProjectStarred,
|
||||
getProjectSessions,
|
||||
loadMoreSessionsForProject,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveProjectName,
|
||||
@@ -612,7 +727,6 @@ export function useSidebarController({
|
||||
confirmDeleteSession,
|
||||
requestProjectDelete,
|
||||
confirmDeleteProject,
|
||||
loadMoreSessions,
|
||||
handleProjectSelect,
|
||||
refreshProjects,
|
||||
updateSessionSummary,
|
||||
|
||||
@@ -6,16 +6,15 @@ export type SessionWithProvider = ProjectSession & {
|
||||
__provider: LLMProvider;
|
||||
};
|
||||
|
||||
export type AdditionalSessionsByProject = Record<string, ProjectSession[]>;
|
||||
export type LoadingSessionsByProject = Record<string, boolean>;
|
||||
|
||||
export type DeleteProjectConfirmation = {
|
||||
project: Project;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
// Delete confirmation payload used by sidebar UX. `projectId`/`provider` are
|
||||
// kept for wiring compatibility, while API deletion now keys only by sessionId.
|
||||
export type SessionDeleteConfirmation = {
|
||||
projectName: string;
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
sessionTitle: string;
|
||||
provider: LLMProvider;
|
||||
@@ -29,7 +28,10 @@ export type SidebarProps = {
|
||||
onSessionSelect: (session: ProjectSession) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
onSessionDelete?: (sessionId: string) => void;
|
||||
onProjectDelete?: (projectName: string) => void;
|
||||
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
|
||||
// `projectId` is the DB identifier; the sidebar hands it back to the parent
|
||||
// when the delete flow completes.
|
||||
onProjectDelete?: (projectId: string) => void;
|
||||
isLoading: boolean;
|
||||
loadingProgress: LoadingProgress | null;
|
||||
onRefresh: () => Promise<void> | void;
|
||||
@@ -55,4 +57,11 @@ export type MCPServerStatus = {
|
||||
isConfigured?: boolean;
|
||||
} | null;
|
||||
|
||||
export type SettingsProject = Pick<Project, 'name' | 'displayName' | 'fullPath' | 'path'>;
|
||||
// Retained as `name` for backwards compatibility with existing settings
|
||||
// consumers; the value is populated from `projectId` by normalizeProjectForSettings.
|
||||
export type SettingsProject = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
fullPath: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type {
|
||||
AdditionalSessionsByProject,
|
||||
ProjectSortOrder,
|
||||
SettingsProject,
|
||||
SessionViewModel,
|
||||
SessionWithProvider,
|
||||
} from '../types/types';
|
||||
import type { ProjectSortOrder, SettingsProject, SessionViewModel, SessionWithProvider } from '../types/types';
|
||||
|
||||
export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
try {
|
||||
@@ -22,20 +16,39 @@ export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadStarredProjects = (): Set<string> => {
|
||||
const LEGACY_STARRED_PROJECTS_STORAGE_KEY = 'starredProjects';
|
||||
|
||||
/**
|
||||
* Reads legacy project stars from localStorage (used only for one-time migration to backend).
|
||||
*/
|
||||
export const readLegacyStarredProjectIds = (): string[] => {
|
||||
try {
|
||||
const saved = localStorage.getItem('starredProjects');
|
||||
return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();
|
||||
const saved = localStorage.getItem(LEGACY_STARRED_PROJECTS_STORAGE_KEY);
|
||||
if (!saved) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.map((value) => String(value).trim())
|
||||
.filter((value) => value.length > 0);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const persistStarredProjects = (starredProjects: Set<string>) => {
|
||||
/**
|
||||
* Clears the legacy localStorage stars key after migration to backend completes.
|
||||
*/
|
||||
export const clearLegacyStarredProjectIds = () => {
|
||||
try {
|
||||
localStorage.setItem('starredProjects', JSON.stringify([...starredProjects]));
|
||||
localStorage.removeItem(LEGACY_STARRED_PROJECTS_STORAGE_KEY);
|
||||
} catch {
|
||||
// Keep UI responsive even if storage fails.
|
||||
// Keep UI responsive even if storage is unavailable.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,14 +111,11 @@ export const createSessionViewModel = (
|
||||
};
|
||||
};
|
||||
|
||||
export const getAllSessions = (
|
||||
project: Project,
|
||||
additionalSessions: AdditionalSessionsByProject,
|
||||
): SessionWithProvider[] => {
|
||||
const claudeSessions = [
|
||||
...(project.sessions || []),
|
||||
...(additionalSessions[project.name] || []),
|
||||
].map((session) => ({ ...session, __provider: 'claude' as const }));
|
||||
export const getAllSessions = (project: Project): SessionWithProvider[] => {
|
||||
const claudeSessions = [...(project.sessions || [])].map((session) => ({
|
||||
...session,
|
||||
__provider: 'claude' as const,
|
||||
}));
|
||||
|
||||
const cursorSessions = (project.cursorSessions || []).map((session) => ({
|
||||
...session,
|
||||
@@ -127,11 +137,8 @@ export const getAllSessions = (
|
||||
);
|
||||
};
|
||||
|
||||
export const getProjectLastActivity = (
|
||||
project: Project,
|
||||
additionalSessions: AdditionalSessionsByProject,
|
||||
): Date => {
|
||||
const sessions = getAllSessions(project, additionalSessions);
|
||||
export const getProjectLastActivity = (project: Project): Date => {
|
||||
const sessions = getAllSessions(project);
|
||||
if (sessions.length === 0) {
|
||||
return new Date(0);
|
||||
}
|
||||
@@ -145,14 +152,13 @@ export const getProjectLastActivity = (
|
||||
export const sortProjects = (
|
||||
projects: Project[],
|
||||
projectSortOrder: ProjectSortOrder,
|
||||
starredProjects: Set<string>,
|
||||
additionalSessions: AdditionalSessionsByProject,
|
||||
): Project[] => {
|
||||
const byName = [...projects];
|
||||
|
||||
byName.sort((projectA, projectB) => {
|
||||
const aStarred = starredProjects.has(projectA.name);
|
||||
const bStarred = starredProjects.has(projectB.name);
|
||||
// Star order now comes from backend `projects.isStarred`.
|
||||
const aStarred = Boolean(projectA.isStarred);
|
||||
const bStarred = Boolean(projectB.isStarred);
|
||||
|
||||
if (aStarred && !bStarred) {
|
||||
return -1;
|
||||
@@ -163,13 +169,10 @@ export const sortProjects = (
|
||||
}
|
||||
|
||||
if (projectSortOrder === 'date') {
|
||||
return (
|
||||
getProjectLastActivity(projectB, additionalSessions).getTime() -
|
||||
getProjectLastActivity(projectA, additionalSessions).getTime()
|
||||
);
|
||||
return getProjectLastActivity(projectB).getTime() - getProjectLastActivity(projectA).getTime();
|
||||
}
|
||||
|
||||
return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);
|
||||
return (projectA.displayName || projectA.projectId).localeCompare(projectB.displayName || projectB.projectId);
|
||||
});
|
||||
|
||||
return byName;
|
||||
@@ -182,9 +185,11 @@ export const filterProjects = (projects: Project[], searchFilter: string): Proje
|
||||
}
|
||||
|
||||
return projects.filter((project) => {
|
||||
const displayName = (project.displayName || project.name).toLowerCase();
|
||||
const projectName = project.name.toLowerCase();
|
||||
return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);
|
||||
const displayName = (project.displayName || project.projectId).toLowerCase();
|
||||
// `project.path`/`fullPath` is the most useful search target now that the
|
||||
// folder-derived name is gone; fall back to displayName above.
|
||||
const searchPath = (project.path || project.fullPath || '').toLowerCase();
|
||||
return displayName.includes(normalizedSearch) || searchPath.includes(normalizedSearch);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -218,12 +223,14 @@ export const normalizeProjectForSettings = (project: Project): SettingsProject =
|
||||
? project.path
|
||||
: '';
|
||||
|
||||
// Legacy SettingsProject still expects a `name` field; use the projectId so
|
||||
// downstream consumers that rely on a stable identifier continue to work.
|
||||
return {
|
||||
name: project.name,
|
||||
name: project.projectId,
|
||||
displayName:
|
||||
typeof project.displayName === 'string' && project.displayName.trim().length > 0
|
||||
? project.displayName
|
||||
: project.name,
|
||||
: project.projectId,
|
||||
fullPath: fallbackPath,
|
||||
path:
|
||||
typeof project.path === 'string' && project.path.length > 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useDeviceSettings } from '../../../hooks/useDeviceSettings';
|
||||
import { useVersionCheck } from '../../../hooks/useVersionCheck';
|
||||
import { useUiPreferences } from '../../../hooks/useUiPreferences';
|
||||
@@ -8,6 +9,7 @@ import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
import type { Project, LLMProvider } from '../../../types/app';
|
||||
import type { MCPServerStatus, SidebarProps } from '../types/types';
|
||||
|
||||
import SidebarCollapsed from './subcomponents/SidebarCollapsed';
|
||||
import SidebarContent from './subcomponents/SidebarContent';
|
||||
import SidebarModals from './subcomponents/SidebarModals';
|
||||
@@ -26,6 +28,7 @@ function Sidebar({
|
||||
onSessionSelect,
|
||||
onNewSession,
|
||||
onSessionDelete,
|
||||
onLoadMoreSessions,
|
||||
onProjectDelete,
|
||||
isLoading,
|
||||
loadingProgress,
|
||||
@@ -53,7 +56,6 @@ function Sidebar({
|
||||
editingProject,
|
||||
showNewProject,
|
||||
editingName,
|
||||
loadingSessions,
|
||||
initialSessionsLoaded,
|
||||
currentTime,
|
||||
isRefreshing,
|
||||
@@ -76,6 +78,8 @@ function Sidebar({
|
||||
toggleStarProject,
|
||||
isProjectStarred,
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
loadMoreSessionsForProject,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
saveProjectName,
|
||||
@@ -83,7 +87,6 @@ function Sidebar({
|
||||
confirmDeleteSession,
|
||||
requestProjectDelete,
|
||||
confirmDeleteProject,
|
||||
loadMoreSessions,
|
||||
handleProjectSelect,
|
||||
refreshProjects,
|
||||
updateSessionSummary,
|
||||
@@ -108,6 +111,7 @@ function Sidebar({
|
||||
onProjectSelect,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
onLoadMoreSessions,
|
||||
onProjectDelete,
|
||||
setCurrentProject,
|
||||
setSidebarVisible: (visible) => setPreference('sidebarVisible', visible),
|
||||
@@ -142,7 +146,6 @@ function Sidebar({
|
||||
expandedProjects,
|
||||
editingProject,
|
||||
editingName,
|
||||
loadingSessions,
|
||||
initialSessionsLoaded,
|
||||
currentTime,
|
||||
editingSession,
|
||||
@@ -151,6 +154,7 @@ function Sidebar({
|
||||
tasksEnabled,
|
||||
mcpServerStatus,
|
||||
getProjectSessions,
|
||||
loadingMoreProjects,
|
||||
isProjectStarred,
|
||||
onEditingNameChange: setEditingName,
|
||||
onToggleProject: toggleProject,
|
||||
@@ -164,9 +168,7 @@ function Sidebar({
|
||||
onDeleteProject: requestProjectDelete,
|
||||
onSessionSelect: handleSessionClick,
|
||||
onDeleteSession: showDeleteSessionConfirmation,
|
||||
onLoadMoreSessions: (project) => {
|
||||
void loadMoreSessions(project);
|
||||
},
|
||||
onLoadMoreSessions: loadMoreSessionsForProject,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange: setEditingSessionName,
|
||||
onStartEditingSession: (sessionId, initialName) => {
|
||||
@@ -234,14 +236,18 @@ function Sidebar({
|
||||
conversationResults={conversationResults}
|
||||
isSearching={isSearching}
|
||||
searchProgress={searchProgress}
|
||||
onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
|
||||
onConversationResultClick={(projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {
|
||||
// `projectId` (DB key) is the canonical identifier post-migration.
|
||||
// The server emits null when it can't resolve a project row for
|
||||
// the search hit; treat that as "no project" and still navigate
|
||||
// to the session so the user can open it from the URL.
|
||||
const resolvedProvider = (provider || 'claude') as LLMProvider;
|
||||
const project = projects.find(p => p.name === projectName);
|
||||
const project = projectId ? projects.find(p => p.projectId === projectId) : null;
|
||||
const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };
|
||||
const sessionObj = {
|
||||
id: sessionId,
|
||||
__provider: resolvedProvider,
|
||||
__projectName: projectName,
|
||||
__projectId: projectId ?? undefined,
|
||||
...searchTarget,
|
||||
};
|
||||
if (project) {
|
||||
@@ -249,12 +255,12 @@ function Sidebar({
|
||||
const sessions = getProjectSessions(project);
|
||||
const existing = sessions.find(s => s.id === sessionId);
|
||||
if (existing) {
|
||||
handleSessionClick({ ...existing, ...searchTarget }, projectName);
|
||||
handleSessionClick({ ...existing, ...searchTarget }, project.projectId);
|
||||
} else {
|
||||
handleSessionClick(sessionObj, projectName);
|
||||
handleSessionClick(sessionObj, project.projectId);
|
||||
}
|
||||
} else {
|
||||
handleSessionClick(sessionObj, projectName);
|
||||
handleSessionClick(sessionObj, projectId ?? '');
|
||||
}
|
||||
}}
|
||||
onRefresh={() => {
|
||||
|
||||
@@ -48,7 +48,9 @@ type SidebarContentProps = {
|
||||
conversationResults: ConversationSearchResults | null;
|
||||
isSearching: boolean;
|
||||
searchProgress: SearchProgress | null;
|
||||
onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||
// Conversation result clicks pass back the DB projectId (or null when the
|
||||
// server couldn't resolve it). Consumers must handle the null case.
|
||||
onConversationResultClick: (projectId: string | null, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
onCreateProject: () => void;
|
||||
@@ -170,10 +172,12 @@ export default function SidebarContent({
|
||||
</div>
|
||||
{projectResult.sessions.map((session) => (
|
||||
<button
|
||||
key={`${projectResult.projectName}-${session.sessionId}`}
|
||||
key={`${projectResult.projectId ?? projectResult.projectName}-${session.sessionId}`}
|
||||
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
|
||||
onClick={() => onConversationResultClick(
|
||||
projectResult.projectName,
|
||||
// Pass the DB projectId (preferred) so the parent can
|
||||
// cross-reference with the loaded projects list.
|
||||
projectResult.projectId,
|
||||
session.sessionId,
|
||||
session.provider || session.matches[0]?.provider || 'claude',
|
||||
session.matches[0]?.timestamp,
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function SidebarModals({
|
||||
<p className="mb-1 text-sm text-muted-foreground">
|
||||
{t('deleteConfirmation.confirmDelete')}{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{deleteConfirmation.project.displayName || deleteConfirmation.project.name}
|
||||
{deleteConfirmation.project.displayName || deleteConfirmation.project.projectId}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||
import { getTaskIndicatorStatus } from '../../utils/utils';
|
||||
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import SidebarProjectSessions from './SidebarProjectSessions';
|
||||
|
||||
@@ -19,7 +21,7 @@ type SidebarProjectItemProps = {
|
||||
editingName: string;
|
||||
sessions: SessionWithProvider[];
|
||||
initialSessionsLoaded: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -40,7 +42,7 @@ type SidebarProjectItemProps = {
|
||||
sessionTitle: string,
|
||||
provider: LLMProvider,
|
||||
) => void;
|
||||
onLoadMoreSessions: (project: Project) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -49,13 +51,9 @@ type SidebarProjectItemProps = {
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const getSessionCountDisplay = (sessions: SessionWithProvider[], hasMoreSessions: boolean): string => {
|
||||
const sessionCount = sessions.length;
|
||||
if (hasMoreSessions && sessionCount >= 5) {
|
||||
return `${sessionCount}+`;
|
||||
}
|
||||
|
||||
return `${sessionCount}`;
|
||||
const getSessionCountDisplay = (project: Project, sessions: SessionWithProvider[]): string => {
|
||||
const total = Number(project.sessionMeta?.total ?? sessions.length);
|
||||
return String(total);
|
||||
};
|
||||
|
||||
export default function SidebarProjectItem({
|
||||
@@ -69,7 +67,7 @@ export default function SidebarProjectItem({
|
||||
editingName,
|
||||
sessions,
|
||||
initialSessionsLoaded,
|
||||
isLoadingSessions,
|
||||
isLoadingMoreSessions,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -93,22 +91,24 @@ export default function SidebarProjectItem({
|
||||
onSaveEditingSession,
|
||||
t,
|
||||
}: SidebarProjectItemProps) {
|
||||
const isSelected = selectedProject?.name === project.name;
|
||||
const isEditing = editingProject === project.name;
|
||||
const hasMoreSessions = project.sessionMeta?.hasMore === true;
|
||||
const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions);
|
||||
const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;
|
||||
// Project identity is tracked by the DB-assigned `projectId` everywhere
|
||||
// after the projectName → projectId migration.
|
||||
const isSelected = selectedProject?.projectId === project.projectId;
|
||||
const isEditing = editingProject === project.projectId;
|
||||
const totalSessionCount = Number(project.sessionMeta?.total ?? sessions.length);
|
||||
const sessionCountDisplay = getSessionCountDisplay(project, sessions);
|
||||
const sessionCountLabel = `${sessionCountDisplay} session${totalSessionCount === 1 ? '' : 's'}`;
|
||||
const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);
|
||||
|
||||
const toggleProject = () => onToggleProject(project.name);
|
||||
const toggleStarProject = () => onToggleStarProject(project.name);
|
||||
const toggleProject = () => onToggleProject(project.projectId);
|
||||
const toggleStarProject = () => onToggleStarProject(project.projectId);
|
||||
|
||||
const saveProjectName = () => {
|
||||
onSaveProjectName(project.name);
|
||||
onSaveProjectName(project.projectId);
|
||||
};
|
||||
|
||||
const selectAndToggleProject = () => {
|
||||
if (selectedProject?.name !== project.name) {
|
||||
if (selectedProject?.projectId !== project.projectId) {
|
||||
onProjectSelect(project);
|
||||
}
|
||||
|
||||
@@ -409,7 +409,8 @@ export default function SidebarProjectItem({
|
||||
sessions={sessions}
|
||||
selectedSession={selectedSession}
|
||||
initialSessionsLoaded={initialSessionsLoaded}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasMoreSessions={Boolean(project.sessionMeta?.hasMore)}
|
||||
isLoadingMoreSessions={isLoadingMoreSessions}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { LoadingProgress, Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type {
|
||||
LoadingSessionsByProject,
|
||||
MCPServerStatus,
|
||||
SessionWithProvider,
|
||||
} from '../../types/types';
|
||||
import type { MCPServerStatus, SessionWithProvider } from '../../types/types';
|
||||
|
||||
import SidebarProjectItem from './SidebarProjectItem';
|
||||
import SidebarProjectsState from './SidebarProjectsState';
|
||||
|
||||
@@ -19,7 +17,6 @@ export type SidebarProjectListProps = {
|
||||
expandedProjects: Set<string>;
|
||||
editingProject: string | null;
|
||||
editingName: string;
|
||||
loadingSessions: LoadingSessionsByProject;
|
||||
initialSessionsLoaded: Set<string>;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
@@ -28,6 +25,8 @@ export type SidebarProjectListProps = {
|
||||
tasksEnabled: boolean;
|
||||
mcpServerStatus: MCPServerStatus;
|
||||
getProjectSessions: (project: Project) => SessionWithProvider[];
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
loadingMoreProjects: Set<string>;
|
||||
isProjectStarred: (projectName: string) => boolean;
|
||||
onEditingNameChange: (value: string) => void;
|
||||
onToggleProject: (projectName: string) => void;
|
||||
@@ -44,7 +43,6 @@ export type SidebarProjectListProps = {
|
||||
sessionTitle: string,
|
||||
provider: LLMProvider,
|
||||
) => void;
|
||||
onLoadMoreSessions: (project: Project) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
onEditingSessionNameChange: (value: string) => void;
|
||||
onStartEditingSession: (sessionId: string, initialName: string) => void;
|
||||
@@ -63,7 +61,6 @@ export default function SidebarProjectList({
|
||||
expandedProjects,
|
||||
editingProject,
|
||||
editingName,
|
||||
loadingSessions,
|
||||
initialSessionsLoaded,
|
||||
currentTime,
|
||||
editingSession,
|
||||
@@ -72,6 +69,8 @@ export default function SidebarProjectList({
|
||||
tasksEnabled,
|
||||
mcpServerStatus,
|
||||
getProjectSessions,
|
||||
onLoadMoreSessions,
|
||||
loadingMoreProjects,
|
||||
isProjectStarred,
|
||||
onEditingNameChange,
|
||||
onToggleProject,
|
||||
@@ -83,7 +82,6 @@ export default function SidebarProjectList({
|
||||
onDeleteProject,
|
||||
onSessionSelect,
|
||||
onDeleteSession,
|
||||
onLoadMoreSessions,
|
||||
onNewSession,
|
||||
onEditingSessionNameChange,
|
||||
onStartEditingSession,
|
||||
@@ -117,19 +115,21 @@ export default function SidebarProjectList({
|
||||
{!showProjects
|
||||
? state
|
||||
: filteredProjects.map((project) => (
|
||||
// React key + per-project state lookups all use the DB `projectId`
|
||||
// so they remain stable across renames and session changes.
|
||||
<SidebarProjectItem
|
||||
key={project.name}
|
||||
key={project.projectId}
|
||||
project={project}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
isExpanded={expandedProjects.has(project.name)}
|
||||
isDeleting={deletingProjects.has(project.name)}
|
||||
isStarred={isProjectStarred(project.name)}
|
||||
isExpanded={expandedProjects.has(project.projectId)}
|
||||
isDeleting={deletingProjects.has(project.projectId)}
|
||||
isStarred={isProjectStarred(project.projectId)}
|
||||
editingProject={editingProject}
|
||||
editingName={editingName}
|
||||
sessions={getProjectSessions(project)}
|
||||
initialSessionsLoaded={initialSessionsLoaded.has(project.name)}
|
||||
isLoadingSessions={Boolean(loadingSessions[project.name])}
|
||||
initialSessionsLoaded={initialSessionsLoaded.has(project.projectId)}
|
||||
isLoadingMoreSessions={loadingMoreProjects.has(project.projectId)}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ChevronDown, Plus } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Button } from '../../../../shared/view/ui';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
|
||||
import SidebarSessionItem from './SidebarSessionItem';
|
||||
|
||||
type SidebarProjectSessionsProps = {
|
||||
@@ -11,7 +13,8 @@ type SidebarProjectSessionsProps = {
|
||||
sessions: SessionWithProvider[];
|
||||
selectedSession: ProjectSession | null;
|
||||
initialSessionsLoaded: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
hasMoreSessions: boolean;
|
||||
isLoadingMoreSessions: boolean;
|
||||
currentTime: Date;
|
||||
editingSession: string | null;
|
||||
editingSessionName: string;
|
||||
@@ -27,7 +30,7 @@ type SidebarProjectSessionsProps = {
|
||||
sessionTitle: string,
|
||||
provider: LLMProvider,
|
||||
) => void;
|
||||
onLoadMoreSessions: (project: Project) => void;
|
||||
onLoadMoreSessions: (projectId: string) => void;
|
||||
onNewSession: (project: Project) => void;
|
||||
t: TFunction;
|
||||
};
|
||||
@@ -56,7 +59,8 @@ export default function SidebarProjectSessions({
|
||||
sessions,
|
||||
selectedSession,
|
||||
initialSessionsLoaded,
|
||||
isLoadingSessions,
|
||||
hasMoreSessions,
|
||||
isLoadingMoreSessions,
|
||||
currentTime,
|
||||
editingSession,
|
||||
editingSessionName,
|
||||
@@ -76,7 +80,6 @@ export default function SidebarProjectSessions({
|
||||
}
|
||||
|
||||
const hasSessions = sessions.length > 0;
|
||||
const hasMoreSessions = project.sessionMeta?.hasMore === true;
|
||||
|
||||
return (
|
||||
<div className="ml-3 space-y-1 border-l border-border pl-3">
|
||||
@@ -105,52 +108,44 @@ export default function SidebarProjectSessions({
|
||||
|
||||
{!initialSessionsLoaded ? (
|
||||
<SessionListSkeleton />
|
||||
) : !hasSessions && !isLoadingSessions ? (
|
||||
) : !hasSessions ? (
|
||||
<div className="px-3 py-2 text-left">
|
||||
<p className="text-xs text-muted-foreground">{t('sessions.noSessions')}</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<SidebarSessionItem
|
||||
key={session.id}
|
||||
project={project}
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
onCancelEditingSession={onCancelEditingSession}
|
||||
onSaveEditingSession={onSaveEditingSession}
|
||||
onProjectSelect={onProjectSelect}
|
||||
onSessionSelect={onSessionSelect}
|
||||
onDeleteSession={onDeleteSession}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<>
|
||||
{sessions.map((session) => (
|
||||
<SidebarSessionItem
|
||||
key={session.id}
|
||||
project={project}
|
||||
session={session}
|
||||
selectedSession={selectedSession}
|
||||
currentTime={currentTime}
|
||||
editingSession={editingSession}
|
||||
editingSessionName={editingSessionName}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEditingSession={onStartEditingSession}
|
||||
onCancelEditingSession={onCancelEditingSession}
|
||||
onSaveEditingSession={onSaveEditingSession}
|
||||
onProjectSelect={onProjectSelect}
|
||||
onSessionSelect={onSessionSelect}
|
||||
onDeleteSession={onDeleteSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasSessions && hasMoreSessions && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2 w-full justify-center gap-2 text-muted-foreground"
|
||||
onClick={() => onLoadMoreSessions(project)}
|
||||
disabled={isLoadingSessions}
|
||||
>
|
||||
{isLoadingSessions ? (
|
||||
<>
|
||||
<div className="h-3 w-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
|
||||
{t('sessions.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{t('sessions.showMore')}
|
||||
</>
|
||||
{hasMoreSessions && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-center text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onLoadMoreSessions(project.projectId)}
|
||||
disabled={isLoadingMoreSessions}
|
||||
>
|
||||
{isLoadingMoreSessions ? t('sessions.loadingSessions') : 'Load more sessions'}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';
|
||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { Badge, Button } from '../../../../shared/view/ui';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { formatTimeAgo } from '../../../../utils/dateUtils';
|
||||
import type { Project, ProjectSession, LLMProvider } from '../../../../types/app';
|
||||
import type { SessionWithProvider } from '../../types/types';
|
||||
import { createSessionViewModel } from '../../utils/utils';
|
||||
@@ -30,6 +30,34 @@ type SidebarSessionItemProps = {
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact relative time for sidebar rows:
|
||||
* <1m, Xm, Xhr, Xd.
|
||||
*/
|
||||
const formatCompactSessionAge = (dateString: string, currentTime: Date): string => {
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(Math.max(0, currentTime.getTime() - date.getTime()) / (1000 * 60));
|
||||
if (diffInMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes}m`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours}hr`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `${diffInDays}d`;
|
||||
};
|
||||
|
||||
export default function SidebarSessionItem({
|
||||
project,
|
||||
session,
|
||||
@@ -48,18 +76,21 @@ export default function SidebarSessionItem({
|
||||
}: SidebarSessionItemProps) {
|
||||
const sessionView = createSessionViewModel(session, currentTime, t);
|
||||
const isSelected = selectedSession?.id === session.id;
|
||||
const compactSessionAge = formatCompactSessionAge(sessionView.sessionTime, currentTime);
|
||||
|
||||
// Sessions are owned by a project identified by `projectId` (DB primary key)
|
||||
// after the projectName → projectId migration.
|
||||
const selectMobileSession = () => {
|
||||
onProjectSelect(project);
|
||||
onSessionSelect(session, project.name);
|
||||
onSessionSelect(session, project.projectId);
|
||||
};
|
||||
|
||||
const saveEditedSession = () => {
|
||||
onSaveEditingSession(project.name, session.id, editingSessionName, session.__provider);
|
||||
onSaveEditingSession(project.projectId, session.id, editingSessionName, session.__provider);
|
||||
};
|
||||
|
||||
const requestDeleteSession = () => {
|
||||
onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider);
|
||||
onDeleteSession(project.projectId, session.id, sessionView.sessionName, session.__provider);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,20 +123,18 @@ export default function SidebarSessionItem({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(sessionView.sessionTime, currentTime, t)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground">{compactSessionAge}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center">
|
||||
{sessionView.messageCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-auto px-1 py-0 text-xs">
|
||||
<Badge variant="secondary" className="px-1 py-0 text-xs">
|
||||
{sessionView.messageCount}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="ml-1 opacity-70">
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,28 +160,21 @@ export default function SidebarSessionItem({
|
||||
'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => onSessionSelect(session, project.name)}
|
||||
onClick={() => onSessionSelect(session, project.projectId)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-start gap-2">
|
||||
<SessionProviderLogo provider={session.__provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
<div className="mt-0.5 flex items-center gap-1">
|
||||
<Clock className="h-2.5 w-2.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimeAgo(sessionView.sessionTime, currentTime, t)}
|
||||
</span>
|
||||
{sessionView.messageCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto px-1 py-0 text-xs transition-opacity group-hover:opacity-0"
|
||||
>
|
||||
{sessionView.messageCount}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xs font-medium text-foreground">{sessionView.sessionName}</div>
|
||||
{compactSessionAge && (
|
||||
<span className="ml-auto flex-shrink-0 text-[11px] text-muted-foreground transition-opacity duration-200 group-hover:opacity-0">
|
||||
{compactSessionAge}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 opacity-70 transition-opacity group-hover:opacity-0">
|
||||
<SessionProviderLogo provider={session.__provider} className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center">
|
||||
{sessionView.messageCount > 0 && <Badge variant="secondary" className="px-1 py-0 text-xs">{sessionView.messageCount}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../../../utils/api';
|
||||
import { useAuth } from '../../auth/context/AuthContext';
|
||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||
@@ -73,11 +74,19 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState<TaskMasterContextError | null>(null);
|
||||
|
||||
const currentProjectNameRef = useRef<string | null>(null);
|
||||
// Track the active project via DB `projectId`; everything downstream uses
|
||||
// the same identifier post-migration.
|
||||
const currentProjectIdRef = useRef<string | null>(null);
|
||||
const projectTaskMasterRef = useRef<TaskMasterProjectInfo | null>(null);
|
||||
const taskMasterRequestSeqRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
currentProjectNameRef.current = currentProject?.name ?? null;
|
||||
}, [currentProject?.name]);
|
||||
currentProjectIdRef.current = currentProject?.projectId ?? null;
|
||||
}, [currentProject?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
projectTaskMasterRef.current = projectTaskMaster;
|
||||
}, [projectTaskMaster]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -88,16 +97,96 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
setError(createTaskMasterError(context, caughtError));
|
||||
}, []);
|
||||
|
||||
const setCurrentProject = useCallback((project: TaskMasterProjectInput) => {
|
||||
const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;
|
||||
setCurrentProjectState(normalizedProject);
|
||||
setProjectTaskMaster(normalizedProject?.taskmaster ?? null);
|
||||
// Looks up projects by DB `projectId`; the legacy folder-derived `name`
|
||||
// field has been removed from Project post-migration.
|
||||
const applyTaskMasterInfo = useCallback((projectId: string, taskMasterInfo: TaskMasterProjectInfo | null) => {
|
||||
setProjectTaskMaster(taskMasterInfo);
|
||||
|
||||
// Project-scoped task data is reset immediately to avoid stale task rendering.
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
setProjects((previousProjects) =>
|
||||
previousProjects.map((project) => {
|
||||
if (project.projectId !== projectId) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return enrichProject({
|
||||
...project,
|
||||
taskmaster: taskMasterInfo ?? undefined,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentProjectState((previousProject) => {
|
||||
if (!previousProject || previousProject.projectId !== projectId) {
|
||||
return previousProject;
|
||||
}
|
||||
|
||||
return enrichProject({
|
||||
...previousProject,
|
||||
taskmaster: taskMasterInfo ?? undefined,
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshCurrentProjectTaskMaster = useCallback(
|
||||
async (projectId: string) => {
|
||||
if (!projectId || !user || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestSequence = ++taskMasterRequestSeqRef.current;
|
||||
|
||||
try {
|
||||
const response = await api.projectTaskmaster(projectId);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch TaskMaster details: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { taskmaster?: TaskMasterProjectInfo };
|
||||
const resolvedTaskMasterInfo = data.taskmaster ?? null;
|
||||
|
||||
if (
|
||||
requestSequence !== taskMasterRequestSeqRef.current
|
||||
|| currentProjectIdRef.current !== projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTaskMasterInfo(projectId, resolvedTaskMasterInfo);
|
||||
} catch (caughtError) {
|
||||
if (
|
||||
requestSequence !== taskMasterRequestSeqRef.current
|
||||
|| currentProjectIdRef.current !== projectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleError('load selected project TaskMaster info', caughtError);
|
||||
}
|
||||
},
|
||||
[applyTaskMasterInfo, handleError, token, user],
|
||||
);
|
||||
|
||||
const setCurrentProject = useCallback(
|
||||
(project: TaskMasterProjectInput) => {
|
||||
const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;
|
||||
setCurrentProjectState(normalizedProject);
|
||||
setProjectTaskMaster(normalizedProject?.taskmaster ?? null);
|
||||
|
||||
// Project-scoped task data is reset immediately to avoid stale task rendering.
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
|
||||
// `projectId` is the DB primary key used for every TaskMaster API call.
|
||||
if (!normalizedProject?.projectId) {
|
||||
taskMasterRequestSeqRef.current += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshCurrentProjectTaskMaster(normalizedProject.projectId);
|
||||
},
|
||||
[refreshCurrentProjectTaskMaster],
|
||||
);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
@@ -121,27 +210,67 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : [];
|
||||
const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
setProjects((previousProjects) => {
|
||||
// Cache is keyed by `projectId` (DB primary key) post-migration.
|
||||
const taskMasterByProjectId = new Map(
|
||||
previousProjects
|
||||
.filter((project) => Boolean(project.taskmaster))
|
||||
.map((project) => [project.projectId, project.taskmaster]),
|
||||
);
|
||||
|
||||
const currentProjectName = currentProjectNameRef.current;
|
||||
if (!currentProjectName) {
|
||||
return enrichedProjects.map((project) => {
|
||||
const cachedTaskMasterInfo = taskMasterByProjectId.get(project.projectId);
|
||||
if (!cachedTaskMasterInfo) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return enrichProject({
|
||||
...project,
|
||||
taskmaster: cachedTaskMasterInfo,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const currentProjectId = currentProjectIdRef.current;
|
||||
if (!currentProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;
|
||||
setCurrentProjectState(matchingProject);
|
||||
setProjectTaskMaster(matchingProject?.taskmaster ?? null);
|
||||
const matchingProject = enrichedProjects.find((project) => project.projectId === currentProjectId) ?? null;
|
||||
|
||||
if (!matchingProject) {
|
||||
taskMasterRequestSeqRef.current += 1;
|
||||
setCurrentProjectState(null);
|
||||
setProjectTaskMaster(null);
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedTaskMasterInfo = matchingProject.taskmaster ?? projectTaskMasterRef.current ?? null;
|
||||
setCurrentProjectState(
|
||||
cachedTaskMasterInfo
|
||||
? enrichProject({
|
||||
...matchingProject,
|
||||
taskmaster: cachedTaskMasterInfo,
|
||||
})
|
||||
: matchingProject,
|
||||
);
|
||||
setProjectTaskMaster(cachedTaskMasterInfo);
|
||||
|
||||
void refreshCurrentProjectTaskMaster(currentProjectId);
|
||||
} catch (caughtError) {
|
||||
handleError('load projects', caughtError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [clearError, handleError, token, user]);
|
||||
}, [clearError, handleError, refreshCurrentProjectTaskMaster, token, user]);
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
const projectName = currentProject?.name;
|
||||
// TaskMaster tasks endpoint now lives under /api/taskmaster/tasks/:projectId.
|
||||
const projectId = currentProject?.projectId;
|
||||
|
||||
if (!projectName || !user || !token) {
|
||||
if (!projectId || !user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
@@ -151,7 +280,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectId)}`);
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to load tasks');
|
||||
@@ -169,7 +298,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [clearError, currentProject?.name, handleError, token, user]);
|
||||
}, [clearError, currentProject?.projectId, handleError, token, user]);
|
||||
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
@@ -204,10 +333,10 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
if (currentProject?.projectId && user && token) {
|
||||
void refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, refreshTasks, token, user]);
|
||||
}, [currentProject?.projectId, refreshTasks, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const message = latestMessage as TaskMasterWebSocketMessage | null;
|
||||
@@ -215,12 +344,16 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-project-updated' && message.projectName) {
|
||||
// Broadcasts now identify projects by `projectId` (see taskmaster-websocket.js).
|
||||
if (message.type === 'taskmaster-project-updated' && message.projectId) {
|
||||
if (message.projectId === currentProjectIdRef.current) {
|
||||
void refreshCurrentProjectTaskMaster(message.projectId);
|
||||
}
|
||||
void refreshProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {
|
||||
if (message.type === 'taskmaster-tasks-updated' && message.projectId === currentProject?.projectId) {
|
||||
void refreshTasks();
|
||||
return;
|
||||
}
|
||||
@@ -228,7 +361,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode })
|
||||
if (message.type === 'taskmaster-mcp-status-changed') {
|
||||
void refreshMCPStatus();
|
||||
}
|
||||
}, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]);
|
||||
}, [currentProject?.projectId, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]);
|
||||
|
||||
const contextValue = useMemo<TaskMasterContextValue>(
|
||||
() => ({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { api } from '../../../utils/api';
|
||||
import type { PrdFile } from '../types';
|
||||
|
||||
type UseProjectPrdFilesOptions = {
|
||||
projectName?: string;
|
||||
// DB primary key of the project (post migration).
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
type PrdResponse = {
|
||||
@@ -23,19 +24,19 @@ function normalizePrdResponse(responseData: PrdResponse): PrdFile[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
|
||||
export function useProjectPrdFiles({ projectId }: UseProjectPrdFilesOptions) {
|
||||
const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);
|
||||
const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);
|
||||
|
||||
const refreshPrdFiles = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
if (!projectId) {
|
||||
setPrdFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingPrdFiles(true);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectId)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setPrdFiles([]);
|
||||
@@ -50,7 +51,7 @@ export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
|
||||
} finally {
|
||||
setIsLoadingPrdFiles(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPrdFiles();
|
||||
|
||||
@@ -90,7 +90,8 @@ export type TaskMasterMcpStatus = {
|
||||
|
||||
export type TaskMasterWebSocketMessage = {
|
||||
type?: string;
|
||||
projectName?: string;
|
||||
// Post-migration TaskMaster broadcasts identify projects by `projectId`.
|
||||
projectId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -72,13 +72,14 @@ export default function TaskBoard({
|
||||
);
|
||||
|
||||
const loadPrdAndOpenEditor = async (prd: PrdFile) => {
|
||||
if (!currentProject?.name) {
|
||||
// Projects are addressed by DB projectId; see the projectName → projectId migration.
|
||||
if (!currentProject?.projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`,
|
||||
`/taskmaster/prd/${encodeURIComponent(currentProject.projectId)}/${encodeURIComponent(prd.name)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
|
||||
const [prdNotification, setPrdNotification] = useState<string | null>(null);
|
||||
const notificationTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name });
|
||||
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectId: currentProject?.projectId });
|
||||
|
||||
const showPrdNotification = useCallback((message: string) => {
|
||||
if (notificationTimeoutRef.current) {
|
||||
|
||||
Reference in New Issue
Block a user