diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index db356ac33..079cbdfad 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -90,6 +90,7 @@ async function main() { await clickButton('Chat'); await clickButton('New Thread'); await waitForText('New thread ready'); + await waitForSelector('[data-testid="thread-list"]'); await setFieldByAriaLabel('Message', 'Please exercise command approval.'); await clickButton('Send'); diff --git a/packages/desktop/src/renderer/api/client.ts b/packages/desktop/src/renderer/api/client.ts index d9edaf633..18e342812 100644 --- a/packages/desktop/src/renderer/api/client.ts +++ b/packages/desktop/src/renderer/api/client.ts @@ -154,6 +154,7 @@ export interface DesktopSessionSummary { sessionId: string; title?: string; cwd?: string; + updatedAt?: string; models?: DesktopSessionModelState; modes?: DesktopSessionModeState; } @@ -806,6 +807,8 @@ function isSessionSummary(value: unknown): value is DesktopSessionSummary { typeof candidate.sessionId === 'string' && (typeof candidate.title === 'string' || candidate.title === undefined) && (typeof candidate.cwd === 'string' || candidate.cwd === undefined) && + (typeof candidate.updatedAt === 'string' || + candidate.updatedAt === undefined) && (candidate.models === undefined || isModelState(candidate.models)) && (candidate.modes === undefined || isModeState(candidate.modes)) ); diff --git a/packages/desktop/src/renderer/components/layout/ProjectSidebar.tsx b/packages/desktop/src/renderer/components/layout/ProjectSidebar.tsx index fe6e2265e..0fb5a79f2 100644 --- a/packages/desktop/src/renderer/components/layout/ProjectSidebar.tsx +++ b/packages/desktop/src/renderer/components/layout/ProjectSidebar.tsx @@ -8,6 +8,12 @@ import type { DesktopProject, DesktopSessionSummary, } from '../../api/client.js'; +import { + FolderIcon, + FolderPlusIcon, + NewThreadIcon, + SlidersIcon, +} from './SidebarIcons.js'; import { ThreadList } from './ThreadList.js'; import type { LoadState } from './types.js'; @@ -15,6 +21,7 @@ export function ProjectSidebar({ activeProject, activeProjectId, activeSessionId, + isDraftSession, loadState, projects, sessions, @@ -27,6 +34,7 @@ export function ProjectSidebar({ activeProject: DesktopProject | null; activeProjectId: string | null; activeSessionId: string | null; + isDraftSession: boolean; loadState: LoadState; projects: DesktopProject[]; sessions: DesktopSessionSummary[]; @@ -42,46 +50,44 @@ export function ProjectSidebar({ aria-label="Projects and threads" data-testid="project-sidebar" > -
- -
-

Qwen Code

-

Desktop

+
+

Projects

+
+ + +
-
- - -
- -
-

Projects

-
- {activeProject?.path || 'No folder selected'} -
- +
-

Threads

+
+

Threads

+ {isDraftSession ? sessions.length + 1 : sessions.length} +
@@ -111,7 +121,7 @@ function ProjectList({ onSelect: (projectId: string) => void; }) { if (projects.length === 0) { - return
No recent projects
; + return
No folder selected
; } return ( @@ -131,10 +141,29 @@ function ProjectList({ onClick={() => onSelect(project.id)} type="button" > - {project.name} - {project.gitBranch || 'No Git branch'} + + + {project.name} + {formatProjectMeta(project)} + ))}
); } + +function formatProjectMeta(project: DesktopProject): string { + const status = project.gitStatus; + const changes = status.modified + status.staged + status.untracked; + const branch = project.gitBranch || 'No Git branch'; + + if (!status.isRepository) { + return 'No Git repository'; + } + + if (changes > 0) { + return `${branch} · ${changes} changes`; + } + + return branch; +} diff --git a/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx b/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx new file mode 100644 index 000000000..970b81623 --- /dev/null +++ b/packages/desktop/src/renderer/components/layout/SidebarIcons.tsx @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SVGProps } from 'react'; + +type SidebarIconProps = SVGProps; + +export function FolderIcon(props: SidebarIconProps) { + return ( + + ); +} + +export function FolderPlusIcon(props: SidebarIconProps) { + return ( + + ); +} + +export function NewThreadIcon(props: SidebarIconProps) { + return ( + + ); +} + +export function SlidersIcon(props: SidebarIconProps) { + return ( + + ); +} + +export function OpenThreadIcon(props: SidebarIconProps) { + return ( + + ); +} diff --git a/packages/desktop/src/renderer/components/layout/ThreadList.tsx b/packages/desktop/src/renderer/components/layout/ThreadList.tsx index f68c6da89..996111b90 100644 --- a/packages/desktop/src/renderer/components/layout/ThreadList.tsx +++ b/packages/desktop/src/renderer/components/layout/ThreadList.tsx @@ -5,17 +5,20 @@ */ import type { DesktopSessionSummary } from '../../api/client.js'; +import { OpenThreadIcon } from './SidebarIcons.js'; export function ThreadList({ activeSessionId, + isDraftSession, sessions, onSelect, }: { activeSessionId: string | null; + isDraftSession: boolean; sessions: DesktopSessionSummary[]; onSelect: (sessionId: string) => void; }) { - if (sessions.length === 0) { + if (!isDraftSession && sessions.length === 0) { return
No sessions
; } @@ -25,21 +28,106 @@ export function ThreadList({ aria-label="Threads" data-testid="thread-list" > - {sessions.map((session) => ( - - ))} + New thread + + +
+ ) : null} + {sessions.map((session) => { + const meta = formatSessionMeta(session); + + return ( + + ); + })} ); } + +function formatSessionMeta(session: DesktopSessionSummary): string | null { + const age = formatSessionAge(session.updatedAt); + if (age) { + return age; + } + + return session.models?.currentModelId + ? shortenModelId(session.models.currentModelId) + : null; +} + +function formatSessionAge(updatedAt: string | undefined): string | null { + if (!updatedAt) { + return null; + } + + const timestamp = Date.parse(updatedAt); + if (Number.isNaN(timestamp)) { + return null; + } + + const elapsedMs = Math.max(0, Date.now() - timestamp); + const minuteMs = 60_000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (elapsedMs < minuteMs) { + return isChineseLocale() ? '刚刚' : 'now'; + } + + if (elapsedMs < hourMs) { + return formatAgeUnit(Math.floor(elapsedMs / minuteMs), 'm', '分'); + } + + if (elapsedMs < dayMs) { + return formatAgeUnit(Math.floor(elapsedMs / hourMs), 'h', '小时'); + } + + return formatAgeUnit(Math.floor(elapsedMs / dayMs), 'd', '天'); +} + +function formatAgeUnit( + value: number, + englishUnit: string, + chineseUnit: string, +) { + return isChineseLocale() + ? `${value} ${chineseUnit}` + : `${value}${englishUnit}`; +} + +function isChineseLocale(): boolean { + return /^zh(?:-|$)/iu.test(globalThis.navigator?.language ?? ''); +} + +function shortenModelId(modelId: string): string { + const normalized = modelId.split('/').pop() ?? modelId; + return normalized.length > 12 ? `${normalized.slice(0, 11)}...` : normalized; +} diff --git a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx index 41d018716..753eba419 100644 --- a/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx +++ b/packages/desktop/src/renderer/components/layout/WorkspacePage.tsx @@ -154,6 +154,7 @@ export function WorkspacePage({ activeProject={activeProject} activeProjectId={activeProjectId} activeSessionId={activeSessionId} + isDraftSession={isDraftSession} loadState={loadState} projects={projects} sessions={sessions} diff --git a/packages/desktop/src/renderer/styles.css b/packages/desktop/src/renderer/styles.css index bd2d3fd5f..00cb458de 100644 --- a/packages/desktop/src/renderer/styles.css +++ b/packages/desktop/src/renderer/styles.css @@ -15,6 +15,7 @@ --muted: #8c948c; --accent: #55a6ff; --accent-soft: rgba(85, 166, 255, 0.16); + --accent-warm: #f2c770; --success: #75d99c; --success-soft: rgba(117, 217, 156, 0.15); --warning: #f0b66e; @@ -24,6 +25,8 @@ color: var(--text); background: var(--bg); font-family: + 'SF Pro Text', + 'SF Pro Display', ui-sans-serif, -apple-system, BlinkMacSystemFont, @@ -79,7 +82,7 @@ summary:focus-visible { .desktop-shell { display: grid; - grid-template-columns: 280px minmax(0, 1fr); + grid-template-columns: 272px minmax(0, 1fr); width: 100%; height: 100vh; overflow: hidden; @@ -93,51 +96,44 @@ summary:focus-visible { height: 100vh; min-height: 0; flex-direction: column; - gap: 18px; + gap: 10px; overflow: hidden; - padding: 18px 14px; + padding: 16px 12px 14px; border-right: 1px solid var(--line); background: - linear-gradient(180deg, rgba(40, 64, 65, 0.82), rgba(16, 19, 20, 0.96)), - #111415; + linear-gradient(180deg, rgba(37, 58, 61, 0.95), rgba(22, 28, 29, 0.98)), + radial-gradient( + circle at 32px 0, + rgba(242, 199, 112, 0.08), + transparent 34% + ), + #151b1d; } -.brand-lockup { +.sidebar-toolbar { display: flex; align-items: center; - gap: 10px; - min-height: 38px; - padding: 2px 4px 0; + justify-content: space-between; + min-height: 30px; + gap: 12px; + padding: 0 4px 4px; } -.brand-mark { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 7px; - background: linear-gradient(135deg, #f2c770, #48a4ff); - color: #0f1112; - font-weight: 850; -} - -.brand-lockup h1, -.brand-lockup p, +.sidebar-toolbar h1, .topbar h2, .topbar p, .panel h3 { margin: 0; } -.brand-lockup h1 { - font-size: 15px; - font-weight: 780; - line-height: 1.1; +.sidebar-toolbar h1 { + color: rgba(225, 232, 228, 0.62); + font-size: 16px; + font-weight: 720; + letter-spacing: 0; + line-height: 1; } -.brand-lockup p, .eyebrow, .message-role { color: var(--muted); @@ -147,24 +143,64 @@ summary:focus-visible { text-transform: uppercase; } -.quick-actions { +.sidebar-toolbar-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.sidebar-icon-button { + position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 8px; + width: 24px; + height: 24px; + place-items: center; + border-radius: 6px; + background: transparent; + color: rgba(225, 232, 228, 0.58); + transition: + background 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.sidebar-icon-button:not(:disabled):hover { + background: rgba(238, 244, 239, 0.08); + color: rgba(245, 248, 244, 0.9); + transform: translateY(-1px); +} + +.sidebar-icon-button svg { + display: block; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; } .sidebar-section { display: flex; min-height: 0; flex-direction: column; - gap: 9px; + gap: 4px; } .sidebar-section-fill { flex: 1; } -.sidebar-section h2 { +.project-navigator { + flex: 0 0 auto; +} + +.sidebar-section h2, +.sidebar-section-heading span { margin: 0; color: var(--text-soft); font-size: 11px; @@ -173,15 +209,25 @@ summary:focus-visible { text-transform: uppercase; } +.sidebar-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 24px; + padding: 4px 8px 0 36px; +} + +.sidebar-section-heading span { + color: rgba(225, 232, 228, 0.38); +} + .empty-row, .workspace-path { min-height: 40px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(10, 12, 13, 0.3); - color: var(--muted); - font-size: 12px; + padding: 10px 2px; + color: rgba(225, 232, 228, 0.52); + font-size: 15px; + font-weight: 680; } .workspace-path { @@ -229,13 +275,13 @@ summary:focus-visible { align-content: start; grid-auto-rows: min-content; min-height: 0; - gap: 7px; + gap: 1px; overflow: auto; - padding-right: 2px; + padding: 0 2px 0 0; } .project-list { - max-height: 190px; + max-height: 132px; } .session-list { @@ -244,45 +290,137 @@ summary:focus-visible { .project-row, .session-row { + position: relative; display: grid; + align-items: center; width: 100%; - min-height: 48px; - gap: 4px; - padding: 10px 12px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: rgba(238, 244, 239, 0.035); - color: var(--text-soft); + min-height: 42px; + border: 0; + border-radius: 6px; + background: transparent; + color: rgba(233, 238, 235, 0.86); text-align: left; + transition: + background 140ms ease, + color 140ms ease; +} + +.project-row { + grid-template-columns: 28px minmax(0, 1fr); + padding: 6px 8px 6px 6px; +} + +.session-row { + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 8px; + padding: 6px 8px 6px 36px; } .project-row-active, .session-row-active { - border-color: rgba(85, 166, 255, 0.48); - background: rgba(85, 166, 255, 0.11); + background: rgba(238, 244, 239, 0.052); color: var(--text); } -.project-row span, -.project-row small, -.session-row span, -.session-row small { +.project-row-active::before, +.session-row-active::before { + position: absolute; + top: 9px; + bottom: 9px; + left: 0; + width: 2px; + border-radius: 999px; + background: rgba(242, 199, 112, 0.74); + content: ''; +} + +.project-row:not(.project-row-active):hover, +.session-row:not(.session-row-active):hover { + background: rgba(238, 244, 239, 0.035); + color: rgba(248, 250, 247, 0.95); +} + +.project-row-icon { + justify-self: start; + width: 18px; + height: 18px; + margin-left: 0; + color: rgba(225, 232, 228, 0.82); +} + +.project-row-copy, +.session-row-title { + min-width: 0; + overflow: hidden; +} + +.project-row-copy { + display: grid; + gap: 2px; +} + +.project-row-copy span, +.project-row-copy small, +.session-row-title, +.session-row-meta { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.project-row span, -.session-row span { - font-size: 13px; +.project-row-copy span, +.session-row-title { + font-size: 14px; + font-weight: 680; + line-height: 1.25; +} + +.project-row-copy small { + color: rgba(225, 232, 228, 0.48); + font-size: 10px; + font-weight: 680; + letter-spacing: 0; +} + +.session-row-trailing { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 32px; + gap: 6px; + color: rgba(225, 232, 228, 0.52); font-weight: 760; } -.project-row small, -.session-row small { - color: var(--muted); +.session-open-icon { + width: 15px; + height: 15px; + color: rgba(225, 232, 228, 0.48); +} + +.session-row:not(:hover):not(.session-row-active) .session-open-icon { + opacity: 0; +} + +.session-ring { + width: 13px; + height: 13px; + border: 2px solid rgba(225, 232, 228, 0.2); + border-top-color: rgba(225, 232, 228, 0.5); + border-radius: 999px; +} + +.session-row-meta { + max-width: 48px; + color: rgba(225, 232, 228, 0.54); font-size: 11px; + font-weight: 720; + line-height: 1; +} + +.session-row-draft { + cursor: default; } .workbench { diff --git a/packages/desktop/src/server/index.test.ts b/packages/desktop/src/server/index.test.ts index 7af09bcb7..254a1b6fc 100644 --- a/packages/desktop/src/server/index.test.ts +++ b/packages/desktop/src/server/index.test.ts @@ -7,7 +7,7 @@ import { execFile } from 'node:child_process'; import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebSocket } from 'ws'; import type { @@ -195,6 +195,51 @@ describe('DesktopServer', () => { }); }); + it('derives recent project names from the project directory', async () => { + const projectPath = await createTempDirectory('qwen-desktop-project-'); + const storePath = join( + await createTempDirectory('qwen-desktop-store-'), + 'desktop-projects.json', + ); + await writeFile( + storePath, + `${JSON.stringify( + { + version: 1, + projects: [ + { + id: 'stored-project', + name: 'Custom Display Name', + path: projectPath, + lastOpenedAt: 1_774_704_300_000, + }, + ], + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const server = await createTestServer(undefined, undefined, storePath); + const listed = await getJson(server, '/api/projects', { + Authorization: 'Bearer test-token', + }); + + expect(listed.status).toBe(200); + expect(listed.body).toMatchObject({ + ok: true, + projects: [ + { + id: 'stored-project', + name: basename(projectPath), + path: projectPath, + }, + ], + }); + expect(JSON.stringify(listed.body)).not.toContain('Custom Display Name'); + }); + it('returns project diffs and can stage and commit changes', async () => { const projectPath = await createCommittedGitProject(); const storePath = join( diff --git a/packages/desktop/src/server/services/projectService.ts b/packages/desktop/src/server/services/projectService.ts index f23a0307e..589055866 100644 --- a/packages/desktop/src/server/services/projectService.ts +++ b/packages/desktop/src/server/services/projectService.ts @@ -78,7 +78,7 @@ export class DesktopProjectService { const path = await normalizeDirectoryPath(projectPath); const storedProject: StoredProject = { id: createProjectId(path), - name: basename(path) || path, + name: getProjectName(path), path, lastOpenedAt: this.now().getTime(), }; @@ -122,6 +122,7 @@ export class DesktopProjectService { const gitStatus = await readGitStatus(project.path); return { ...project, + name: getProjectName(project.path), gitBranch: gitStatus.branch, gitStatus, }; @@ -308,6 +309,10 @@ function createProjectId(path: string): string { return createHash('sha256').update(path).digest('hex').slice(0, 16); } +function getProjectName(path: string): string { + return basename(path) || path; +} + function isProjectStoreFile(value: unknown): value is ProjectStoreFile { if (!value || typeof value !== 'object') { return false;