Skip to content

Commit f01c66b

Browse files
committed
feat(renderer): polish thread, home, and welcome chrome
- add rounded/transparent tabs and a header-style Browser MCP indicator - move Browser MCP toggles into the draft composer and make the active-thread chip read-only - refresh project switching, home, and welcome presentation styling - update thread view tests for the new header behavior
1 parent 7a47933 commit f01c66b

12 files changed

Lines changed: 265 additions & 137 deletions

src/renderer/components/common/LightballTabs.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export function LightballTabs<K extends string>(props: {
2929
equalWidth?: boolean;
3030
/** Delay text-color flip to match ball arrival (~80ms). */
3131
delayActiveText?: boolean;
32+
/** Container/tab corner shape. Defaults to a full pill. */
33+
shape?: "pill" | "rounded";
34+
/** Drop the container background and border; useful when embedding into
35+
* a parent surface that already provides chrome. */
36+
transparent?: boolean;
3237
}) {
3338
const {
3439
tabs,
@@ -38,7 +43,14 @@ export function LightballTabs<K extends string>(props: {
3843
className,
3944
equalWidth = false,
4045
delayActiveText = false,
46+
shape = "pill",
47+
transparent = false,
4148
} = props;
49+
const containerRadiusClass = shape === "rounded" ? "rounded-xl" : "rounded-full";
50+
const tabRadiusClass = shape === "rounded" ? "rounded-lg" : "rounded-full";
51+
const containerChromeClass = transparent
52+
? ""
53+
: "border border-border/15 bg-surface-tertiary/40 backdrop-blur-md";
4254

4355
const listRef = useRef<HTMLDivElement>(null);
4456
const buttonRefs = useRef<Partial<Record<K, HTMLButtonElement | null>>>({});
@@ -103,7 +115,7 @@ export function LightballTabs<K extends string>(props: {
103115
role="tablist"
104116
aria-label={ariaLabel}
105117
onKeyDown={handleKey}
106-
className={`relative inline-flex h-7 items-center rounded-full border border-border/15 bg-surface-tertiary/40 p-0.5 backdrop-blur-md ${className ?? ""}`}
118+
className={`relative inline-flex h-7 items-center ${containerRadiusClass} ${containerChromeClass} p-0.5 ${className ?? ""}`}
107119
>
108120
<FlyingLightball
109121
containerRef={listRef}
@@ -128,7 +140,7 @@ export function LightballTabs<K extends string>(props: {
128140
tabIndex={isActive ? 0 : -1}
129141
disabled={tab.disabled}
130142
onClick={() => selectTab(tab.id)}
131-
className={`relative ${equalWidth ? "flex-1" : ""} flex h-full items-center justify-center gap-1.5 rounded-full px-3 text-[11px] font-semibold tracking-tight outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-focus/50 ${
143+
className={`relative ${equalWidth ? "flex-1" : ""} flex h-full items-center justify-center gap-1.5 ${tabRadiusClass} px-3 text-[11px] font-semibold tracking-tight outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-focus/50 ${
132144
litText ? "text-foreground" : "text-muted/60"
133145
}`}
134146
>

src/renderer/components/composer/AttachmentBar.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,38 @@
11
import type { ReactNode } from "react";
2+
import { Tooltip } from "@heroui/react";
23
import { Globe, X } from "lucide-react";
34
import { getEntryIconUrl } from "@/renderer/components/common/fileIcons";
45
import { toLocalFileUrl } from "@/shared/promptContent";
56
import type { Attachment } from "./useAttachments";
67

7-
export function BrowserChip(props: { onRemove?: (() => void) | undefined; title?: string }) {
8-
const { onRemove, title = "Browser MCP enabled for this thread" } = props;
8+
export function BrowserChip(props: {
9+
onRemove?: (() => void) | undefined;
10+
title?: string;
11+
variant?: "chip" | "header";
12+
}) {
13+
const { onRemove, title = "Browser MCP enabled for this thread", variant = "chip" } = props;
14+
if (variant === "header") {
15+
// Same structure as the other header buttons (CircleCheck / ArrowRightLeft
16+
// / Bug / X) so the indicator slots into the row without alignment drift.
17+
// Non-interactive — mid-thread browserMcp changes can't reconfigure the
18+
// running session — but rendering as <button> keeps it consistent with the
19+
// sibling status icon, which is also a no-op button.
20+
return (
21+
<Tooltip delay={0}>
22+
<Tooltip.Trigger>
23+
<button
24+
type="button"
25+
className="lightcode-overlay-header__controls shrink-0 rounded p-1 text-muted/60 transition-colors hover:bg-white/[0.06] hover:text-foreground"
26+
aria-label={title}
27+
onClick={(e) => e.stopPropagation()}
28+
>
29+
<Globe className="size-3.5" aria-hidden="true" />
30+
</button>
31+
</Tooltip.Trigger>
32+
<Tooltip.Content>{title}</Tooltip.Content>
33+
</Tooltip>
34+
);
35+
}
936
return (
1037
<div
1138
className="lightcode-attachment-chip lightcode-browser-chip"

src/renderer/components/thread/PresentationModeTabs.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export function PresentationModeTabs(props: PresentationModeTabsProps) {
4040
className="w-[140px]"
4141
equalWidth
4242
delayActiveText
43+
shape="rounded"
44+
transparent
4345
/>
4446
</div>
4547
);

src/renderer/components/thread/ProjectSwitchMenu.tsx

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { startTransition } from "react";
2-
import { ChevronDown, FolderOpen, Monitor } from "lucide-react";
2+
import { ChevronDown, FolderOpen, House, Monitor } from "lucide-react";
33
import { Dropdown, Label } from "@heroui/react";
44
import { useShallow } from "zustand/shallow";
55
import type { Project } from "@/shared/contracts";
6+
import { HOME_PROJECT_NAME, isHomeProject, isHomeProjectId } from "@/shared/homeScope";
67
import { makeDraftPaneId } from "@/shared/paneId";
78
import { useAppStore } from "@/renderer/state/appStore";
89
import { TuxIcon } from "@/renderer/components/common/TuxIcon";
910

10-
function LocationIcon(props: { kind: Project["location"]["kind"] }) {
11+
function LocationIcon(props: { kind: Project["location"]["kind"]; className?: string }) {
12+
const className = `${props.className ?? "size-4"} shrink-0 text-muted`;
1113
if (props.kind === "wsl") {
12-
return <TuxIcon className="size-4 shrink-0 text-muted" />;
14+
return <TuxIcon className={className} />;
1315
}
1416
if (props.kind === "windows") {
15-
return <Monitor className="size-4 shrink-0 text-muted" />;
17+
return <Monitor className={className} />;
1618
}
17-
return <FolderOpen className="size-4 shrink-0 text-muted" />;
19+
return <FolderOpen className={className} />;
1820
}
1921

2022
export function ProjectSwitchMenu(props: {
@@ -24,12 +26,24 @@ export function ProjectSwitchMenu(props: {
2426
paneId?: string;
2527
}) {
2628
const { currentProjectId, variant, paneId } = props;
27-
const projects = useAppStore(useShallow((state) => state.projects.filter((p) => !p.disabled)));
29+
// Show every selectable project. Home is intentionally stored with
30+
// `disabled: true` as an internal marker (it's not a user-disabled
31+
// project), so we let it through the filter and only exclude
32+
// user-disabled regular projects.
33+
const projects = useAppStore(
34+
useShallow((state) => state.projects.filter((p) => isHomeProject(p) || !p.disabled)),
35+
);
2836
const openDraft = useAppStore((state) => state.openDraft);
2937
const replacePaneId = useAppStore((state) => state.replacePaneId);
3038

3139
const current = projects.find((p) => p.id === currentProjectId);
32-
const label = current?.name ?? "Select project";
40+
const isHomeCurrent = isHomeProjectId(currentProjectId);
41+
const label = isHomeCurrent ? HOME_PROJECT_NAME : (current?.name ?? "Select project");
42+
const triggerIcon = isHomeCurrent ? (
43+
<House className="size-3.5 shrink-0 text-muted" />
44+
) : current ? (
45+
<LocationIcon kind={current.location.kind} className="size-3.5" />
46+
) : null;
3347
const isDisabled = projects.length <= 1;
3448

3549
function handleSelect(nextProjectId: string) {
@@ -51,12 +65,20 @@ export function ProjectSwitchMenu(props: {
5165
onAction={(key) => handleSelect(String(key))}
5266
className="lightcode-menu min-w-56"
5367
>
54-
{projects.map((project) => (
55-
<Dropdown.Item key={project.id} id={project.id} textValue={project.name}>
56-
<LocationIcon kind={project.location.kind} />
57-
<Label>{project.name}</Label>
58-
</Dropdown.Item>
59-
))}
68+
{projects.map((project) => {
69+
const isHome = isHomeProject(project);
70+
const itemLabel = isHome ? HOME_PROJECT_NAME : project.name;
71+
return (
72+
<Dropdown.Item key={project.id} id={project.id} textValue={itemLabel}>
73+
{isHome ? (
74+
<House className="size-4 shrink-0 text-muted" />
75+
) : (
76+
<LocationIcon kind={project.location.kind} />
77+
)}
78+
<Label>{itemLabel}</Label>
79+
</Dropdown.Item>
80+
);
81+
})}
6082
</Dropdown.Menu>
6183
);
6284

@@ -87,6 +109,7 @@ export function ProjectSwitchMenu(props: {
87109
isDisabled={isDisabled}
88110
className="group inline-flex min-w-0 max-w-full items-center gap-1 rounded px-1 py-0.5 text-sm leading-tight text-muted/60 outline-none transition-colors hover:bg-white/[0.04] hover:text-foreground focus-visible:bg-white/[0.04] disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-muted/60"
89111
>
112+
{triggerIcon}
90113
<span className="min-w-0 truncate">{label}</span>
91114
{!isDisabled ? (
92115
<ChevronDown className="size-3 shrink-0 opacity-60 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />

src/renderer/components/thread/ThreadComposerSection.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
MentionInput,
1919
useAttachments,
2020
} from "../composer";
21-
import { getBrowserMcpScope } from "../composer/browserMcpScope";
2221
import type { MentionInputHandle } from "../composer";
2322
import { flattenSegments } from "../composer/serializeMentions";
2423
import { getComposerControls } from "../providers";
@@ -332,8 +331,11 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread
332331
const presentationMode =
333332
thread.presentationMode ?? agentStatus?.capabilities.presentationMode ?? "terminal";
334333
const usesTerminalPresentation = presentationMode === "terminal";
335-
const browserMcpScope = getBrowserMcpScope(thread.agentKind, presentationMode);
336-
const browserMcpToggleableHere = browserMcpScope === "always";
334+
// Browser MCP is bound at session-create time for every provider, so a
335+
// mid-thread toggle would not actually attach (or detach) the MCP server in
336+
// the running agent process. The toggle is hidden in the active-thread
337+
// composer; users set it in the draft composer before launch.
338+
const browserMcpToggleableHere = false;
337339
const availableCommands = resolveAvailableSlashCommands(
338340
thread.slashCommands,
339341
agentStatus?.capabilities.slashCommands,

src/renderer/components/thread/ThreadDraftChrome.tsx

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,7 @@ export function ThreadDraftDropIndicators(props: {
9999
);
100100
}
101101

102-
export function ThreadDraftHero(props: {
103-
compact?: boolean | undefined;
104-
projectId: string;
105-
scopeLabel?: string | undefined;
106-
paneId?: string | undefined;
107-
}) {
102+
export function ThreadDraftHero(props: { compact?: boolean | undefined }) {
108103
return (
109104
<div className="flex flex-1 flex-col items-center justify-center">
110105
<div className="w-full max-w-[920px] overflow-visible pb-3 text-center">
@@ -115,21 +110,6 @@ export function ThreadDraftHero(props: {
115110
Lightcode
116111
</span>
117112
</h1>
118-
<div
119-
className={`mt-1.5 flex justify-center ${props.compact ? "text-[clamp(0.6875rem,1.05vw,0.8125rem)]" : "text-[clamp(0.75rem,1.35vw,0.9375rem)]"}`}
120-
>
121-
{props.scopeLabel ? (
122-
<span className="min-w-0 truncate pb-[0.08em] leading-snug font-medium tracking-normal text-transparent [background-image:linear-gradient(135deg,var(--muted)_0%,color-mix(in_oklab,var(--accent)_30%,var(--muted))_100%)] [background-size:100%_100%] bg-clip-text font-mono">
123-
{props.scopeLabel}
124-
</span>
125-
) : (
126-
<ProjectSwitchMenu
127-
currentProjectId={props.projectId}
128-
variant="hero"
129-
{...(props.paneId ? { paneId: props.paneId } : {})}
130-
/>
131-
)}
132-
</div>
133113
</div>
134114
</div>
135115
);

src/renderer/components/thread/ThreadDraftView.tsx

Lines changed: 53 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from "./threadDraftViewHelpers";
3636
import { friendlyError } from "@/shared/messages";
3737
import { PresentationModeTabs } from "./PresentationModeTabs";
38+
import { ProjectSwitchMenu } from "./ProjectSwitchMenu";
3839
import { ThreadDraftComposerArea, type DraftStartInput } from "./ThreadDraftComposerArea";
3940
import type { ComposerControl } from "./ThreadComposer";
4041
import { AgentDiscoveryScreen } from "./AgentDiscoveryScreen";
@@ -803,6 +804,42 @@ export function ThreadDraftView(props: {
803804
props.paneAlign === "right" ? "ml-auto" : props.paneAlign === "left" ? "mr-auto" : "mx-auto";
804805
const paddingClass = "px-2";
805806

807+
const handlePresentationChange = (next: ThreadPresentationMode) => {
808+
// If the active provider can't serve this surface, swap to another
809+
// installed provider that can — the provider-switch effect will then
810+
// reload the per-provider config snapshot.
811+
if (!supportedPresentationModes.includes(next)) {
812+
const fallback = installedAgents.find((agent) => {
813+
const modes = agent.capabilities.presentationModes ?? [agent.capabilities.presentationMode];
814+
return modes.includes(next);
815+
});
816+
if (!fallback) return;
817+
setPresentationMode(next);
818+
setAgentKind(fallback.kind);
819+
return;
820+
}
821+
setPresentationMode(next);
822+
// Drop config values that the new presentation surface doesn't
823+
// support (e.g. Codex plan mode is ACP-only).
824+
const normalizer = effectiveAgentKind ? getConfigNormalizer(effectiveAgentKind) : undefined;
825+
if (!normalizer) return;
826+
const patch = normalizer({
827+
capabilities: capabilitiesForPresentation(selectedAgent.capabilities, next),
828+
config: {
829+
model,
830+
effort,
831+
...(contextSize ? { contextSize } : {}),
832+
...(fast ? { fast } : {}),
833+
...(thinking ? { thinking } : {}),
834+
mode,
835+
approvalPolicy,
836+
sandboxMode,
837+
},
838+
presentationMode: next,
839+
});
840+
if (Object.keys(patch).length > 0) onConfigPatch(patch);
841+
};
842+
806843
return (
807844
<div
808845
ref={props.droppableRef}
@@ -821,64 +858,26 @@ export function ThreadDraftView(props: {
821858
/>
822859
)}
823860
<div
824-
className={`${props.compact ? alignClass : "mx-auto"} relative flex h-full min-h-0 w-full max-w-[1040px] flex-col ${paddingClass} px-3 pb-2 ${props.compact ? "" : "pt-2"}`}
861+
className={`${props.compact ? alignClass : "mx-auto justify-center"} relative flex h-full min-h-0 w-full max-w-[1040px] flex-col ${paddingClass} px-3 pb-2 ${props.compact ? "" : "pt-2"}`}
825862
>
826863
<ThreadDraftDropIndicators dropIndicator={props.dropIndicator} />
827-
<ThreadDraftHero
828-
compact={props.compact}
829-
projectId={project.id}
830-
{...(scopeLabel ? { scopeLabel } : {})}
831-
{...(props.paneId ? { paneId: props.paneId } : {})}
832-
/>
833-
834-
<PresentationModeTabs
835-
presentationMode={presentationMode}
836-
supportsTerminal={supportsTerminalMode}
837-
supportsGui={supportsGuiMode}
838-
className={`${props.compact ? alignClass : "mx-auto"} mb-1 w-full max-w-[920px]`}
839-
onChange={(next) => {
840-
// If the active provider can't serve this surface, swap to
841-
// another installed provider that can — the provider-switch
842-
// effect will then reload the per-provider config snapshot.
843-
if (!supportedPresentationModes.includes(next)) {
844-
const fallback = installedAgents.find((agent) => {
845-
const modes = agent.capabilities.presentationModes ?? [
846-
agent.capabilities.presentationMode,
847-
];
848-
return modes.includes(next);
849-
});
850-
if (!fallback) return;
851-
setPresentationMode(next);
852-
setAgentKind(fallback.kind);
853-
return;
854-
}
855-
setPresentationMode(next);
856-
// Drop config values that the new presentation surface
857-
// doesn't support (e.g. Codex plan mode is ACP-only).
858-
const normalizer = effectiveAgentKind
859-
? getConfigNormalizer(effectiveAgentKind)
860-
: undefined;
861-
if (!normalizer) return;
862-
const patch = normalizer({
863-
capabilities: capabilitiesForPresentation(selectedAgent.capabilities, next),
864-
config: {
865-
model,
866-
effort,
867-
...(contextSize ? { contextSize } : {}),
868-
...(fast ? { fast } : {}),
869-
...(thinking ? { thinking } : {}),
870-
mode,
871-
approvalPolicy,
872-
sandboxMode,
873-
},
874-
presentationMode: next,
875-
});
876-
if (Object.keys(patch).length > 0) onConfigPatch(patch);
877-
}}
878-
/>
864+
{props.compact ? <ThreadDraftHero compact={props.compact} /> : null}
879865

880866
{/* Composer at bottom */}
881-
<div className={`${props.compact ? alignClass : "mx-auto"} w-full max-w-[920px]`}>
867+
<div className={`${props.compact ? alignClass : "mx-auto"} w-full max-w-[720px]`}>
868+
<div className="mb-1 flex items-center justify-between gap-2">
869+
<ProjectSwitchMenu
870+
currentProjectId={project.id}
871+
variant="compact"
872+
{...(props.paneId ? { paneId: props.paneId } : {})}
873+
/>
874+
<PresentationModeTabs
875+
presentationMode={presentationMode}
876+
supportsTerminal={supportsTerminalMode}
877+
supportsGui={supportsGuiMode}
878+
onChange={handlePresentationChange}
879+
/>
880+
</div>
882881
<ThreadDraftComposerArea
883882
project={project}
884883
{...(props.paneId ? { paneId: props.paneId } : {})}

0 commit comments

Comments
 (0)