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"
>
-
-
- Q
-
-
-
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) => (
-
);
}
+
+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;