mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
feat(desktop): polish project sidebar
This commit is contained in:
parent
d772bf8b56
commit
c3a989e15a
9 changed files with 555 additions and 120 deletions
|
|
@ -90,6 +90,7 @@ async function main() {
|
||||||
await clickButton('Chat');
|
await clickButton('Chat');
|
||||||
await clickButton('New Thread');
|
await clickButton('New Thread');
|
||||||
await waitForText('New thread ready');
|
await waitForText('New thread ready');
|
||||||
|
await waitForSelector('[data-testid="thread-list"]');
|
||||||
|
|
||||||
await setFieldByAriaLabel('Message', 'Please exercise command approval.');
|
await setFieldByAriaLabel('Message', 'Please exercise command approval.');
|
||||||
await clickButton('Send');
|
await clickButton('Send');
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ export interface DesktopSessionSummary {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
updatedAt?: string;
|
||||||
models?: DesktopSessionModelState;
|
models?: DesktopSessionModelState;
|
||||||
modes?: DesktopSessionModeState;
|
modes?: DesktopSessionModeState;
|
||||||
}
|
}
|
||||||
|
|
@ -806,6 +807,8 @@ function isSessionSummary(value: unknown): value is DesktopSessionSummary {
|
||||||
typeof candidate.sessionId === 'string' &&
|
typeof candidate.sessionId === 'string' &&
|
||||||
(typeof candidate.title === 'string' || candidate.title === undefined) &&
|
(typeof candidate.title === 'string' || candidate.title === undefined) &&
|
||||||
(typeof candidate.cwd === 'string' || candidate.cwd === undefined) &&
|
(typeof candidate.cwd === 'string' || candidate.cwd === undefined) &&
|
||||||
|
(typeof candidate.updatedAt === 'string' ||
|
||||||
|
candidate.updatedAt === undefined) &&
|
||||||
(candidate.models === undefined || isModelState(candidate.models)) &&
|
(candidate.models === undefined || isModelState(candidate.models)) &&
|
||||||
(candidate.modes === undefined || isModeState(candidate.modes))
|
(candidate.modes === undefined || isModeState(candidate.modes))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ import type {
|
||||||
DesktopProject,
|
DesktopProject,
|
||||||
DesktopSessionSummary,
|
DesktopSessionSummary,
|
||||||
} from '../../api/client.js';
|
} from '../../api/client.js';
|
||||||
|
import {
|
||||||
|
FolderIcon,
|
||||||
|
FolderPlusIcon,
|
||||||
|
NewThreadIcon,
|
||||||
|
SlidersIcon,
|
||||||
|
} from './SidebarIcons.js';
|
||||||
import { ThreadList } from './ThreadList.js';
|
import { ThreadList } from './ThreadList.js';
|
||||||
import type { LoadState } from './types.js';
|
import type { LoadState } from './types.js';
|
||||||
|
|
||||||
|
|
@ -15,6 +21,7 @@ export function ProjectSidebar({
|
||||||
activeProject,
|
activeProject,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
isDraftSession,
|
||||||
loadState,
|
loadState,
|
||||||
projects,
|
projects,
|
||||||
sessions,
|
sessions,
|
||||||
|
|
@ -27,6 +34,7 @@ export function ProjectSidebar({
|
||||||
activeProject: DesktopProject | null;
|
activeProject: DesktopProject | null;
|
||||||
activeProjectId: string | null;
|
activeProjectId: string | null;
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
|
isDraftSession: boolean;
|
||||||
loadState: LoadState;
|
loadState: LoadState;
|
||||||
projects: DesktopProject[];
|
projects: DesktopProject[];
|
||||||
sessions: DesktopSessionSummary[];
|
sessions: DesktopSessionSummary[];
|
||||||
|
|
@ -42,46 +50,44 @@ export function ProjectSidebar({
|
||||||
aria-label="Projects and threads"
|
aria-label="Projects and threads"
|
||||||
data-testid="project-sidebar"
|
data-testid="project-sidebar"
|
||||||
>
|
>
|
||||||
<div className="brand-lockup">
|
<div className="sidebar-toolbar">
|
||||||
<div className="brand-mark" aria-hidden="true">
|
<h1>Projects</h1>
|
||||||
Q
|
<div className="sidebar-toolbar-actions">
|
||||||
</div>
|
<button
|
||||||
<div>
|
aria-label="Settings"
|
||||||
<h1>Qwen Code</h1>
|
className="sidebar-icon-button"
|
||||||
<p>Desktop</p>
|
title="Settings"
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
>
|
||||||
|
<SlidersIcon />
|
||||||
|
<span className="sr-only">Settings</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="New Thread"
|
||||||
|
className="sidebar-icon-button"
|
||||||
|
disabled={loadState.state !== 'ready' || !activeProject}
|
||||||
|
title="New Thread"
|
||||||
|
type="button"
|
||||||
|
onClick={onCreateSession}
|
||||||
|
>
|
||||||
|
<NewThreadIcon />
|
||||||
|
<span className="sr-only">New Thread</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Open Project"
|
||||||
|
className="sidebar-icon-button"
|
||||||
|
title="Open Project"
|
||||||
|
type="button"
|
||||||
|
onClick={onChooseWorkspace}
|
||||||
|
>
|
||||||
|
<FolderPlusIcon />
|
||||||
|
<span className="sr-only">Open Project</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="sidebar-section quick-actions">
|
<section className="sidebar-section project-navigator">
|
||||||
<button
|
|
||||||
className="primary-button"
|
|
||||||
disabled={loadState.state !== 'ready' || !activeProject}
|
|
||||||
type="button"
|
|
||||||
onClick={onCreateSession}
|
|
||||||
>
|
|
||||||
New Thread
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="sidebar-section">
|
|
||||||
<h2>Projects</h2>
|
|
||||||
<div className="workspace-path">
|
|
||||||
{activeProject?.path || 'No folder selected'}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="secondary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={onChooseWorkspace}
|
|
||||||
>
|
|
||||||
Open Project
|
|
||||||
</button>
|
|
||||||
<ProjectList
|
<ProjectList
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
|
@ -90,9 +96,13 @@ export function ProjectSidebar({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="sidebar-section sidebar-section-fill">
|
<section className="sidebar-section sidebar-section-fill">
|
||||||
<h2>Threads</h2>
|
<div className="sidebar-section-heading">
|
||||||
|
<h2>Threads</h2>
|
||||||
|
<span>{isDraftSession ? sessions.length + 1 : sessions.length}</span>
|
||||||
|
</div>
|
||||||
<ThreadList
|
<ThreadList
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
|
isDraftSession={isDraftSession}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
onSelect={onSelectSession}
|
onSelect={onSelectSession}
|
||||||
/>
|
/>
|
||||||
|
|
@ -111,7 +121,7 @@ function ProjectList({
|
||||||
onSelect: (projectId: string) => void;
|
onSelect: (projectId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
return <div className="empty-row">No recent projects</div>;
|
return <div className="empty-row">No folder selected</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,10 +141,29 @@ function ProjectList({
|
||||||
onClick={() => onSelect(project.id)}
|
onClick={() => onSelect(project.id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span>{project.name}</span>
|
<FolderIcon className="project-row-icon" />
|
||||||
<small>{project.gitBranch || 'No Git branch'}</small>
|
<span className="project-row-copy">
|
||||||
|
<span>{project.name}</span>
|
||||||
|
<small>{formatProjectMeta(project)}</small>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
125
packages/desktop/src/renderer/components/layout/SidebarIcons.tsx
Normal file
125
packages/desktop/src/renderer/components/layout/SidebarIcons.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
type SidebarIconProps = SVGProps<SVGSVGElement>;
|
||||||
|
|
||||||
|
export function FolderIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.5 7.8c0-1.1.9-2 2-2h4.2l1.8 2.1h7c1.1 0 2 .9 2 2v7.5c0 1.1-.9 2-2 2h-13c-1.1 0-2-.9-2-2V7.8Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.8 11h16.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderPlusIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.5 7.8c0-1.1.9-2 2-2h4.2l1.8 2.1h7c1.1 0 2 .9 2 2v7.5c0 1.1-.9 2-2 2h-13c-1.1 0-2-.9-2-2V7.8Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15.5 14.9h4.2m-2.1-2.1V17"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewThreadIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.2 17.8 17.8 6.2M12.8 6.2h5v5M6.2 11.2v6.6h6.6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlidersIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="20"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.5 7h13M8.5 12h7M10.5 17h3"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenThreadIcon(props: SidebarIconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="18"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M8.2 7.2h8.6v8.6M16.4 7.6 7.2 16.8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,17 +5,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { DesktopSessionSummary } from '../../api/client.js';
|
import type { DesktopSessionSummary } from '../../api/client.js';
|
||||||
|
import { OpenThreadIcon } from './SidebarIcons.js';
|
||||||
|
|
||||||
export function ThreadList({
|
export function ThreadList({
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
|
isDraftSession,
|
||||||
sessions,
|
sessions,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
|
isDraftSession: boolean;
|
||||||
sessions: DesktopSessionSummary[];
|
sessions: DesktopSessionSummary[];
|
||||||
onSelect: (sessionId: string) => void;
|
onSelect: (sessionId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (sessions.length === 0) {
|
if (!isDraftSession && sessions.length === 0) {
|
||||||
return <div className="empty-row">No sessions</div>;
|
return <div className="empty-row">No sessions</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,21 +28,106 @@ export function ThreadList({
|
||||||
aria-label="Threads"
|
aria-label="Threads"
|
||||||
data-testid="thread-list"
|
data-testid="thread-list"
|
||||||
>
|
>
|
||||||
{sessions.map((session) => (
|
{isDraftSession ? (
|
||||||
<button
|
<div
|
||||||
className={
|
className="session-row session-row-active session-row-draft"
|
||||||
session.sessionId === activeSessionId
|
role="status"
|
||||||
? 'session-row session-row-active'
|
|
||||||
: 'session-row'
|
|
||||||
}
|
|
||||||
key={session.sessionId}
|
|
||||||
onClick={() => onSelect(session.sessionId)}
|
|
||||||
type="button"
|
|
||||||
>
|
>
|
||||||
<span>{session.title || session.sessionId}</span>
|
<span className="session-row-title">New thread</span>
|
||||||
<small>{session.cwd || session.sessionId}</small>
|
<span className="session-row-trailing">
|
||||||
</button>
|
<span className="session-ring" aria-hidden="true" />
|
||||||
))}
|
<span className="session-row-meta">draft</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const meta = formatSessionMeta(session);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={
|
||||||
|
session.sessionId === activeSessionId
|
||||||
|
? 'session-row session-row-active'
|
||||||
|
: 'session-row'
|
||||||
|
}
|
||||||
|
key={session.sessionId}
|
||||||
|
onClick={() => onSelect(session.sessionId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="session-row-title">
|
||||||
|
{session.title || session.sessionId}
|
||||||
|
</span>
|
||||||
|
<span className="session-row-trailing">
|
||||||
|
{session.sessionId === activeSessionId ? (
|
||||||
|
<span className="session-ring" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<OpenThreadIcon className="session-open-icon" />
|
||||||
|
)}
|
||||||
|
{meta ? <span className="session-row-meta">{meta}</span> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ export function WorkspacePage({
|
||||||
activeProject={activeProject}
|
activeProject={activeProject}
|
||||||
activeProjectId={activeProjectId}
|
activeProjectId={activeProjectId}
|
||||||
activeSessionId={activeSessionId}
|
activeSessionId={activeSessionId}
|
||||||
|
isDraftSession={isDraftSession}
|
||||||
loadState={loadState}
|
loadState={loadState}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
--muted: #8c948c;
|
--muted: #8c948c;
|
||||||
--accent: #55a6ff;
|
--accent: #55a6ff;
|
||||||
--accent-soft: rgba(85, 166, 255, 0.16);
|
--accent-soft: rgba(85, 166, 255, 0.16);
|
||||||
|
--accent-warm: #f2c770;
|
||||||
--success: #75d99c;
|
--success: #75d99c;
|
||||||
--success-soft: rgba(117, 217, 156, 0.15);
|
--success-soft: rgba(117, 217, 156, 0.15);
|
||||||
--warning: #f0b66e;
|
--warning: #f0b66e;
|
||||||
|
|
@ -24,6 +25,8 @@
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
font-family:
|
font-family:
|
||||||
|
'SF Pro Text',
|
||||||
|
'SF Pro Display',
|
||||||
ui-sans-serif,
|
ui-sans-serif,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
|
|
@ -79,7 +82,7 @@ summary:focus-visible {
|
||||||
|
|
||||||
.desktop-shell {
|
.desktop-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 280px minmax(0, 1fr);
|
grid-template-columns: 272px minmax(0, 1fr);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -93,51 +96,44 @@ summary:focus-visible {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 18px 14px;
|
padding: 16px 12px 14px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(40, 64, 65, 0.82), rgba(16, 19, 20, 0.96)),
|
linear-gradient(180deg, rgba(37, 58, 61, 0.95), rgba(22, 28, 29, 0.98)),
|
||||||
#111415;
|
radial-gradient(
|
||||||
|
circle at 32px 0,
|
||||||
|
rgba(242, 199, 112, 0.08),
|
||||||
|
transparent 34%
|
||||||
|
),
|
||||||
|
#151b1d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup {
|
.sidebar-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
justify-content: space-between;
|
||||||
min-height: 38px;
|
min-height: 30px;
|
||||||
padding: 2px 4px 0;
|
gap: 12px;
|
||||||
|
padding: 0 4px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.sidebar-toolbar h1,
|
||||||
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,
|
|
||||||
.topbar h2,
|
.topbar h2,
|
||||||
.topbar p,
|
.topbar p,
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup h1 {
|
.sidebar-toolbar h1 {
|
||||||
font-size: 15px;
|
color: rgba(225, 232, 228, 0.62);
|
||||||
font-weight: 780;
|
font-size: 16px;
|
||||||
line-height: 1.1;
|
font-weight: 720;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup p,
|
|
||||||
.eyebrow,
|
.eyebrow,
|
||||||
.message-role {
|
.message-role {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
@ -147,24 +143,64 @@ summary:focus-visible {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
.sidebar-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-icon-button {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
width: 24px;
|
||||||
gap: 8px;
|
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 {
|
.sidebar-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 9px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-fill {
|
.sidebar-section-fill {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section h2 {
|
.project-navigator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section h2,
|
||||||
|
.sidebar-section-heading span {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text-soft);
|
color: var(--text-soft);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|
@ -173,15 +209,25 @@ summary:focus-visible {
|
||||||
text-transform: uppercase;
|
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,
|
.empty-row,
|
||||||
.workspace-path {
|
.workspace-path {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 10px 12px;
|
padding: 10px 2px;
|
||||||
border: 1px solid var(--line);
|
color: rgba(225, 232, 228, 0.52);
|
||||||
border-radius: var(--radius);
|
font-size: 15px;
|
||||||
background: rgba(10, 12, 13, 0.3);
|
font-weight: 680;
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-path {
|
.workspace-path {
|
||||||
|
|
@ -229,13 +275,13 @@ summary:focus-visible {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
grid-auto-rows: min-content;
|
grid-auto-rows: min-content;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 7px;
|
gap: 1px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding-right: 2px;
|
padding: 0 2px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-list {
|
.project-list {
|
||||||
max-height: 190px;
|
max-height: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list {
|
.session-list {
|
||||||
|
|
@ -244,45 +290,137 @@ summary:focus-visible {
|
||||||
|
|
||||||
.project-row,
|
.project-row,
|
||||||
.session-row {
|
.session-row {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 48px;
|
min-height: 42px;
|
||||||
gap: 4px;
|
border: 0;
|
||||||
padding: 10px 12px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--line);
|
background: transparent;
|
||||||
border-radius: var(--radius);
|
color: rgba(233, 238, 235, 0.86);
|
||||||
background: rgba(238, 244, 239, 0.035);
|
|
||||||
color: var(--text-soft);
|
|
||||||
text-align: left;
|
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,
|
.project-row-active,
|
||||||
.session-row-active {
|
.session-row-active {
|
||||||
border-color: rgba(85, 166, 255, 0.48);
|
background: rgba(238, 244, 239, 0.052);
|
||||||
background: rgba(85, 166, 255, 0.11);
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row span,
|
.project-row-active::before,
|
||||||
.project-row small,
|
.session-row-active::before {
|
||||||
.session-row span,
|
position: absolute;
|
||||||
.session-row small {
|
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;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row span,
|
.project-row-copy span,
|
||||||
.session-row span {
|
.session-row-title {
|
||||||
font-size: 13px;
|
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;
|
font-weight: 760;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-row small,
|
.session-open-icon {
|
||||||
.session-row small {
|
width: 15px;
|
||||||
color: var(--muted);
|
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-size: 11px;
|
||||||
|
font-weight: 720;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row-draft {
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workbench {
|
.workbench {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import type {
|
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 () => {
|
it('returns project diffs and can stage and commit changes', async () => {
|
||||||
const projectPath = await createCommittedGitProject();
|
const projectPath = await createCommittedGitProject();
|
||||||
const storePath = join(
|
const storePath = join(
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export class DesktopProjectService {
|
||||||
const path = await normalizeDirectoryPath(projectPath);
|
const path = await normalizeDirectoryPath(projectPath);
|
||||||
const storedProject: StoredProject = {
|
const storedProject: StoredProject = {
|
||||||
id: createProjectId(path),
|
id: createProjectId(path),
|
||||||
name: basename(path) || path,
|
name: getProjectName(path),
|
||||||
path,
|
path,
|
||||||
lastOpenedAt: this.now().getTime(),
|
lastOpenedAt: this.now().getTime(),
|
||||||
};
|
};
|
||||||
|
|
@ -122,6 +122,7 @@ export class DesktopProjectService {
|
||||||
const gitStatus = await readGitStatus(project.path);
|
const gitStatus = await readGitStatus(project.path);
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
|
name: getProjectName(project.path),
|
||||||
gitBranch: gitStatus.branch,
|
gitBranch: gitStatus.branch,
|
||||||
gitStatus,
|
gitStatus,
|
||||||
};
|
};
|
||||||
|
|
@ -308,6 +309,10 @@ function createProjectId(path: string): string {
|
||||||
return createHash('sha256').update(path).digest('hex').slice(0, 16);
|
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 {
|
function isProjectStoreFile(value: unknown): value is ProjectStoreFile {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue