Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ deepcode -c personal-openrouter -m <model-id> --effort auto
### Appearance and imported themes

**Settings → Appearance** controls the machine-local theme, conversation width,
and typography. **Import VS Code theme** accepts one local `.json` or `.jsonc`
and typography. The conversation column also has a drag handle on its right
edge: dragging resizes it, the arrow keys step by 5%, Home and End jump to the
ends of the range, and a double click restores the default. Each of those writes
the same stored preference the slider edits — a drag stays below 100, which is
the built-in cap rather than "fill the workspace". **Import VS Code theme**
accepts one local `.json` or `.jsonc`
color-theme file, validates every value in its `colors` object, and maps the
supported workbench colors onto DeepCode's complete palette. Unmapped tokens
come from the inferred light or dark base, so an imported theme never leaves a
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ComposerLaunchIntent,
} from "./features/execution/Composer";
import { DesktopSidebar } from "./features/navigation/DesktopSidebar";
import { ConversationSplitter } from "./features/thread/ConversationSplitter";
import { ThreadHeader } from "./features/thread/ThreadHeader";
import { useTranscriptMode } from "./features/thread/transcriptMode";
import type { ClientRuntime } from "./rpc/contracts";
Expand Down Expand Up @@ -417,6 +418,7 @@ export function App({ runtime }: { runtime: ClientRuntime }) {
/>
</Suspense>
)}
{showingThreads && selectedThread ? <ConversationSplitter /> : null}
</section>

{inspectorVisible ? (
Expand Down
58 changes: 49 additions & 9 deletions desktop/src/app/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,33 @@ function clampNumber(value: unknown, min: number, max: number, fallback: number)
return Math.min(max, Math.max(min, Math.round(parsed)));
}

/**
* The conversation column's own slider domain. Exported because the drag
* handle that resizes the same column reads its floor, ceiling and step from
* here rather than restating them.
*/
export const CONVERSATION_WIDTH_RANGE = {
min: 40,
max: 100,
step: 5,
unit: "%",
} as const;

const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = {
key: "conversationWidth",
label: "Conversation width",
description:
"How much of the window the conversation column fills. The default keeps " +
"lines short for readability; widen it to use more of a large display.",
cssVariable: "--conversation-width",
range: { min: 40, max: 100, step: 5, unit: "%" },
range: CONVERSATION_WIDTH_RANGE,
sanitize: (value) =>
clampNumber(value, 40, 100, APPEARANCE_DEFAULTS.conversationWidth),
clampNumber(
value,
CONVERSATION_WIDTH_RANGE.min,
CONVERSATION_WIDTH_RANGE.max,
APPEARANCE_DEFAULTS.conversationWidth,
),
// 100% restores the built-in cap rather than stretching edge to edge, so
// the default stays exactly what it was before this setting existed.
toCss: (value) => (value >= 100 ? "min(820px, 100%)" : `${value}%`),
Expand Down Expand Up @@ -198,16 +215,39 @@ export function writeAppearance(state: AppearanceState): void {
* Push the state onto `root`, clearing anything left at its default so the
* stylesheet's own value shows through instead of a duplicate copy of it.
*/
/**
* Push one setting onto `root`, clearing it when it sits at its default so the
* stylesheet's own value shows through instead of a duplicate copy of it.
*
* Exported for the conversation pane's drag handle (#151): a drag previews its
* own setting on every pointermove, and that preview must not restyle the theme
* dozens of times a second to move one column.
*/
export function applyAppearanceSetting<K extends keyof AppearanceState>(
key: K,
value: AppearanceState[K],
root: HTMLElement,
): void {
// Matched by key, so narrowing the table's union to this key is sound.
const setting = APPEARANCE_SETTINGS.find((candidate) => candidate.key === key) as
| AppearanceSetting<K>
| undefined;
if (!setting?.cssVariable) return;
const rendered = setting.toCss ? setting.toCss(value) : String(value);
if (rendered === "" || value === APPEARANCE_DEFAULTS[key]) {
root.style.removeProperty(setting.cssVariable);
} else {
root.style.setProperty(setting.cssVariable, rendered);
}
}

/**
* Push the state onto `root` — one pass over the table, in table order.
*/
export function applyAppearance(state: AppearanceState, root: HTMLElement): void {
for (const setting of APPEARANCE_SETTINGS) {
if (!setting.cssVariable) continue;
const value = state[setting.key] as never;
const rendered = setting.toCss ? setting.toCss(value) : String(value);
if (rendered === "" || value === APPEARANCE_DEFAULTS[setting.key]) {
root.style.removeProperty(setting.cssVariable);
} else {
root.style.setProperty(setting.cssVariable, rendered);
}
applyAppearanceSetting(setting.key, state[setting.key], root);
}

applyImportedTheme(state.theme === "imported" ? state.importedTheme : null, root);
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ const ZH_CN: Record<string, string> = {
"thread.review": "审查",
"thread.closeReview": "关闭审查面板",
"thread.openReview": "打开审查面板",
"thread.splitterLabel": "调整对话宽度",
"thread.splitterHint": "拖动调整对话宽度 · 双击恢复默认",
// Approval card
"approval.label": "需要审批",
"approval.decision": "决定: {{status}}",
Expand Down
21 changes: 20 additions & 1 deletion desktop/src/app/useAppearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useSyncExternalStore } from "react";
import {
APPEARANCE_DEFAULTS,
applyAppearance,
applyAppearanceSetting,
readAppearance,
writeAppearance,
type AppearanceState,
Expand Down Expand Up @@ -32,6 +33,22 @@ function flush(): void {
if (element) applyAppearance(state, element);
}

/**
* Paint a width that is not stored yet — a drag in progress — or put the stored
* one back with `null`.
*
* Storage is untouched either way and only the width property moves, so this
* stays one apply path rather than a second one: the next `commit` repaints
* everything from `state`, which also means a commit made during a drag wins
* until the next pointermove previews again.
*/
function previewConversationWidth(value: number | null): void {
const element = root();
if (element) {
applyAppearanceSetting("conversationWidth", value ?? state.conversationWidth, element);
}
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
// The first subscriber marks the app as mounted; paint the saved
Expand Down Expand Up @@ -61,6 +78,8 @@ export interface AppearanceController {
/** Update related preferences atomically (used when importing a palette). */
update(patch: Partial<AppearanceState>): void;
reset(): void;
/** Show an in-progress width without storing it; `null` restores the stored one. */
previewConversationWidth(value: number | null): void;
}

export function useAppearance(): AppearanceController {
Expand All @@ -80,7 +99,7 @@ export function useAppearance(): AppearanceController {

const reset = useCallback(() => commit({ ...APPEARANCE_DEFAULTS }), []);

return { appearance, set, update, reset };
return { appearance, set, update, reset, previewConversationWidth };
}

/** Reset module state between tests. */
Expand Down
65 changes: 65 additions & 0 deletions desktop/src/features/thread/ConversationSplitter.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* Drag handle on the right edge of the centred conversation column (#151).

It reads the same --conversation-width the column, its composer and the goal
rail read, so the strip sits on the edge all three share: half its own width
to the right of it, which is what measureColumnWidth() reads back off the DOM
to turn a drag into a share of the workspace. The one caveat — the strip is
anchored to the workspace while the column is centred inside the scroller, so
a scrollbar moves the true edge by a few pixels without moving that offset —
is documented in conversationWidthResize.ts. */
.splitter {
position: absolute;
top: 0;
bottom: 0;
left: calc(50% + var(--conversation-width) * 0.5);
width: 12px;
margin-left: -6px;
border-radius: var(--radius-pill);
cursor: col-resize;
touch-action: none;
}

.splitter::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 1px;
height: min(180px, 40%);
transform: translate(-50%, -50%);
border-radius: var(--radius-pill);
background: var(--border-strong);
opacity: 0.55;
/* Not repeated in a reduced-motion block: tokens.css already forces
`transition-duration: 0.01ms` on everything under that preference. */
transition:
width 120ms ease,
opacity 120ms ease,
background 120ms ease;
}

.splitter:hover::before,
.splitter:focus-visible::before,
.splitter[data-dragging="true"]::before {
width: 2px;
background: var(--border-emphasis);
opacity: 1;
}

/* tokens.css outlines the form controls and nothing else; this is a
role="separator" div, so it brings its own ring. */
.splitter:focus-visible {
outline: 3px solid var(--signal-soft);
outline-offset: 1px;
}

/* Under this width the workspace is at or below the 820px cap the default stands
for — 1080px window − 248px sidebar − 12px workspace margins (an open
inspector, or a topic rail, narrows it further) — so the column already spans
the space and the strip would sit on the scrollbar. The settings slider still
resizes the column there. */
@media (max-width: 1080px) {
.splitter {
display: none;
}
}
Loading
Loading