Foundry UI polish: real org data, project icons, model picker (#233)

* feat: modernize chat UI and rename handoff to task

- Remove agent message bubbles, keep user bubbles (right-aligned)
- Rename "Handoffs" to "Tasks" with ListChecks icon in sidebar
- Move model picker inside composer, add renderFooter to ChatComposer SDK
- Make project sections collapsible with hover-only chevrons
- Remove divider between chat and composer
- Update model picker chevron to flip on open/close
- Replace all user-visible "handoff" strings with "task" across frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: real org mock data, model picker styling, project icons, task minutes indicator

- Replace fake acme/* mock data with real rivet-dev GitHub org repos and PRs
- Fix model picker popover: dark gray surface with backdrop blur instead of pure black
- Add colored letter icons to project section headers (swap to chevron on hover)
- Add "847 min used" indicator in transcript header
- Rename browser tab title from OpenHandoff to Foundry
- Reduce transcript header title font weight to 500

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Kissel 2026-03-10 23:49:48 -07:00 committed by GitHub
parent 34a0587cbc
commit 32008797da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 895 additions and 607 deletions

View file

@ -10,7 +10,7 @@
"license": { "license": {
"name": "Apache-2.0" "name": "Apache-2.0"
}, },
"version": "0.3.0" "version": "0.3.1"
}, },
"servers": [ "servers": [
{ {

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
</script> </script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenHandoff</title> <title>Foundry</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -352,7 +352,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
const changeModel = useCallback( const changeModel = useCallback(
(model: ModelId) => { (model: ModelId) => {
if (!promptTab) { if (!promptTab) {
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`); throw new Error(`Unable to change model for task ${handoff.id} without an active prompt tab`);
} }
void handoffWorkbenchClient.changeModel({ void handoffWorkbenchClient.changeModel({
@ -487,7 +487,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
}} }}
> >
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2> <h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>Sessions are where you chat with the agent. Start one now to send the first prompt on this handoff.</p> <p style={{ margin: 0, opacity: 0.75 }}>
Sessions are where you chat with the agent. Start one now to send the first prompt on this task.
</p>
<button <button
type="button" type="button"
onClick={addTab} onClick={addTab}
@ -661,15 +663,15 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
void (async () => { void (async () => {
const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? ""; const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? "";
if (!repoId) { if (!repoId) {
throw new Error("Cannot create a handoff without an available repo"); throw new Error("Cannot create a task without an available repo");
} }
const task = window.prompt("Describe the handoff task", "Investigate and implement the requested change"); const task = window.prompt("Describe the task", "Investigate and implement the requested change");
if (!task) { if (!task) {
return; return;
} }
const title = window.prompt("Optional handoff title", "")?.trim() || undefined; const title = window.prompt("Optional task title", "")?.trim() || undefined;
const branch = window.prompt("Optional branch name", "")?.trim() || undefined; const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({ const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
repoId, repoId,
@ -692,7 +694,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const openDiffTab = useCallback( const openDiffTab = useCallback(
(path: string) => { (path: string) => {
if (!activeHandoff) { if (!activeHandoff) {
throw new Error("Cannot open a diff tab without an active handoff"); throw new Error("Cannot open a diff tab without an active task");
} }
setOpenDiffsByHandoff((current) => { setOpenDiffsByHandoff((current) => {
const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]); const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]);
@ -736,10 +738,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
(id: string) => { (id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id); const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) { if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`); throw new Error(`Unable to rename missing task ${id}`);
} }
const nextTitle = window.prompt("Rename handoff", currentHandoff.title); const nextTitle = window.prompt("Rename task", currentHandoff.title);
if (nextTitle === null) { if (nextTitle === null) {
return; return;
} }
@ -758,7 +760,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
(id: string) => { (id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id); const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) { if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`); throw new Error(`Unable to rename missing task ${id}`);
} }
const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? ""); const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? "");
@ -778,14 +780,14 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const archiveHandoff = useCallback(() => { const archiveHandoff = useCallback(() => {
if (!activeHandoff) { if (!activeHandoff) {
throw new Error("Cannot archive without an active handoff"); throw new Error("Cannot archive without an active task");
} }
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id }); void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff]); }, [activeHandoff]);
const publishPr = useCallback(() => { const publishPr = useCallback(() => {
if (!activeHandoff) { if (!activeHandoff) {
throw new Error("Cannot publish PR without an active handoff"); throw new Error("Cannot publish PR without an active task");
} }
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id }); void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff]); }, [activeHandoff]);
@ -793,7 +795,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const revertFile = useCallback( const revertFile = useCallback(
(path: string) => { (path: string) => {
if (!activeHandoff) { if (!activeHandoff) {
throw new Error("Cannot revert a file without an active handoff"); throw new Error("Cannot revert a file without an active task");
} }
setOpenDiffsByHandoff((current) => ({ setOpenDiffsByHandoff((current) => ({
...current, ...current,
@ -968,10 +970,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
gap: "12px", gap: "12px",
}} }}
> >
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first handoff</h2> <h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
<p style={{ margin: 0, opacity: 0.75 }}> <p style={{ margin: 0, opacity: 0.75 }}>
{viewModel.repos.length > 0 {viewModel.repos.length > 0
? "Start from the sidebar to create a handoff on the first available repo." ? "Start from the sidebar to create a task on the first available repo."
: "No repos are available in this workspace yet."} : "No repos are available in this workspace yet."}
</p> </p>
<button <button
@ -989,7 +991,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
fontWeight: 600, fontWeight: 600,
}} }}
> >
New handoff New task
</button> </button>
</div> </div>
</div> </div>

View file

@ -43,7 +43,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
> >
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}> <div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
<LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}> <LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
Handoff Events Task Events
</LabelXSmall> </LabelXSmall>
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall> <LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
</div> </div>

View file

@ -55,11 +55,11 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
borderBottomRightRadius: "4px", borderBottomRightRadius: "4px",
} }
: { : {
backgroundColor: "rgba(255, 255, 255, 0.06)", backgroundColor: "transparent",
border: `1px solid ${theme.colors.borderOpaque}`, border: "none",
color: "#e4e4e7", color: "#e4e4e7",
borderBottomLeftRadius: "4px", borderRadius: "0",
borderBottomRightRadius: "16px", padding: "0",
}), }),
})} })}
> >
@ -163,12 +163,6 @@ export const MessageList = memo(function MessageList({
}), }),
message: css({ message: css({
display: "flex", display: "flex",
'&[data-variant="user"]': {
justifyContent: "flex-end",
},
'&[data-variant="assistant"]': {
justifyContent: "flex-start",
},
}), }),
messageContent: messageContentClass, messageContent: messageContentClass,
messageText: css({ messageText: css({
@ -193,6 +187,11 @@ export const MessageList = memo(function MessageList({
return ( return (
<> <>
<style>{`
[data-variant="user"] > [data-slot="message-content"] {
margin-left: auto;
}
`}</style>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null} {historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div <div
ref={scrollRef} ref={scrollRef}

View file

@ -1,7 +1,7 @@
import { memo, useState } from "react"; import { memo, useState } from "react";
import { useStyletron } from "baseui"; import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover"; import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, Star } from "lucide-react"; import { ChevronDown, ChevronUp, Star } from "lucide-react";
import { AgentIcon } from "./ui"; import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model"; import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
@ -23,7 +23,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
const [hoveredId, setHoveredId] = useState<ModelId | null>(null); const [hoveredId, setHoveredId] = useState<ModelId | null>(null);
return ( return (
<div className={css({ minWidth: "200px", padding: "4px 0" })}> <div className={css({ minWidth: "220px", padding: "6px 0" })}>
{MODEL_GROUPS.map((group) => ( {MODEL_GROUPS.map((group) => (
<div key={group.provider}> <div key={group.provider}>
<div <div
@ -62,7 +62,10 @@ const ModelPickerContent = memo(function ModelPickerContent({
fontSize: "12px", fontSize: "12px",
fontWeight: isActive ? 600 : 400, fontWeight: isActive ? 600 : 400,
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary, color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" }, borderRadius: "6px",
marginLeft: "4px",
marginRight: "4px",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})} })}
> >
<AgentIcon agent={agent} size={12} /> <AgentIcon agent={agent} size={12} />
@ -100,22 +103,26 @@ export const ModelPicker = memo(function ModelPicker({
onSetDefault: (id: ModelId) => void; onSetDefault: (id: ModelId) => void;
}) { }) {
const [css, theme] = useStyletron(); const [css, theme] = useStyletron();
const [isOpen, setIsOpen] = useState(false);
return ( return (
<StatefulPopover <StatefulPopover
placement={PLACEMENT.topLeft} placement={PLACEMENT.topLeft}
triggerType="click" triggerType="click"
autoFocus={false} autoFocus={false}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
overrides={{ overrides={{
Body: { Body: {
style: { style: {
backgroundColor: "#000000", backgroundColor: "rgba(32, 32, 32, 0.98)",
borderTopLeftRadius: "8px", backdropFilter: "blur(12px)",
borderTopRightRadius: "8px", borderTopLeftRadius: "10px",
borderBottomLeftRadius: "8px", borderTopRightRadius: "10px",
borderBottomRightRadius: "8px", borderBottomLeftRadius: "10px",
border: `1px solid ${theme.colors.borderOpaque}`, borderBottomRightRadius: "10px",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)", border: "1px solid rgba(255, 255, 255, 0.10)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)",
zIndex: 100, zIndex: 100,
}, },
}, },
@ -141,13 +148,13 @@ export const ModelPicker = memo(function ModelPicker({
fontSize: "12px", fontSize: "12px",
fontWeight: 500, fontWeight: 500,
color: theme.colors.contentSecondary, color: theme.colors.contentSecondary,
backgroundColor: theme.colors.backgroundTertiary, backgroundColor: "rgba(255, 255, 255, 0.10)",
border: `1px solid ${theme.colors.borderOpaque}`, border: "1px solid rgba(255, 255, 255, 0.14)",
":hover": { color: theme.colors.contentPrimary }, ":hover": { color: theme.colors.contentPrimary, backgroundColor: "rgba(255, 255, 255, 0.14)" },
})} })}
> >
{modelLabel(value)} {modelLabel(value)}
<ChevronDown size={11} /> {isOpen ? <ChevronDown size={11} /> : <ChevronUp size={11} />}
</button> </button>
</div> </div>
</StatefulPopover> </StatefulPopover>

View file

@ -43,25 +43,27 @@ export const PromptComposer = memo(function PromptComposer({
backgroundColor: "rgba(255, 255, 255, 0.06)", backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`, border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px", borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`, minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 36}px`,
transition: "border-color 200ms ease", transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" }, ":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
display: "flex",
flexDirection: "column",
}), }),
input: css({ input: css({
display: "block", display: "block",
width: "100%", width: "100%",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`, minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 20}px`,
padding: "12px 58px 12px 14px", padding: "14px 58px 8px 14px",
background: "transparent", background: "transparent",
border: "none", border: "none",
borderRadius: "16px", borderRadius: "16px 16px 0 0",
color: theme.colors.contentPrimary, color: theme.colors.contentPrimary,
fontSize: "13px", fontSize: "13px",
fontFamily: "inherit", fontFamily: "inherit",
resize: "none", resize: "none",
outline: "none", outline: "none",
lineHeight: "1.4", lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT}px`, maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT + 40}px`,
boxSizing: "border-box", boxSizing: "border-box",
overflowY: "hidden", overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary }, "::placeholder": { color: theme.colors.contentSecondary },
@ -101,7 +103,7 @@ export const PromptComposer = memo(function PromptComposer({
<div <div
className={css({ className={css({
padding: "12px 16px", padding: "12px 16px",
borderTop: `1px solid ${theme.colors.borderOpaque}`, borderTop: "none",
flexShrink: 0, flexShrink: 0,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@ -151,13 +153,22 @@ export const PromptComposer = memo(function PromptComposer({
}} }}
placeholder={placeholder} placeholder={placeholder}
inputRef={textareaRef} inputRef={textareaRef}
rows={1} rows={2}
allowEmptySubmit={isRunning} allowEmptySubmit={isRunning}
submitLabel={isRunning ? "Stop" : "Send"} submitLabel={isRunning ? "Stop" : "Send"}
classNames={composerClassNames} classNames={composerClassNames}
renderSubmitContent={() => (isRunning ? <Square size={16} /> : <ArrowUpFromLine size={16} />)} renderSubmitContent={() => (isRunning ? <Square size={16} /> : <ArrowUpFromLine size={16} />)}
renderFooter={() => (
<div className={css({ padding: "0 10px 8px" })}>
<ModelPicker
value={model}
defaultModel={defaultModel}
onChange={onChangeModel}
onSetDefault={onSetDefaultModel}
/>
</div>
)}
/> />
<ModelPicker value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
</div> </div>
); );
}); });

View file

@ -1,11 +1,27 @@
import { memo, useState } from "react"; import { memo, useState } from "react";
import { useStyletron } from "baseui"; import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography"; import { LabelSmall, LabelXSmall } from "baseui/typography";
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react"; import { ChevronDown, ChevronUp, CloudUpload, GitPullRequestDraft, ListChecks, Plus } from "lucide-react";
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model"; import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
import { ContextMenuOverlay, HandoffIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; import { ContextMenuOverlay, HandoffIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
function projectInitial(label: string): string {
const parts = label.split("/");
const name = parts[parts.length - 1] ?? label;
return name.charAt(0).toUpperCase();
}
function projectIconColor(label: string): string {
let hash = 0;
for (let i = 0; i < label.length; i++) {
hash = (hash * 31 + label.charCodeAt(i)) | 0;
}
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
}
export const Sidebar = memo(function Sidebar({ export const Sidebar = memo(function Sidebar({
projects, projects,
activeId, activeId,
@ -25,13 +41,25 @@ export const Sidebar = memo(function Sidebar({
}) { }) {
const [css, theme] = useStyletron(); const [css, theme] = useStyletron();
const contextMenu = useContextMenu(); const contextMenu = useContextMenu();
const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>({}); const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
return ( return (
<SPanel> <SPanel>
<style>{`
[data-project-header]:hover [data-chevron] {
display: inline-flex !important;
}
[data-project-header]:hover [data-project-icon] {
display: none !important;
}
`}</style>
<PanelHeaderBar> <PanelHeaderBar>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}> <LabelSmall
Handoffs color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, flex: 1, fontSize: "13px", display: "flex", alignItems: "center", gap: "6px" }}
>
<ListChecks size={14} />
Tasks
</LabelSmall> </LabelSmall>
<button <button
onClick={onCreate} onClick={onCreate}
@ -56,38 +84,78 @@ export const Sidebar = memo(function Sidebar({
<ScrollBody> <ScrollBody>
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}> <div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
{projects.map((project) => { {projects.map((project) => {
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5); const isCollapsed = collapsedProjects[project.id] === true;
const hiddenCount = Math.max(0, project.handoffs.length - visibleCount);
return ( return (
<div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}> <div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div <div
onClick={() =>
setCollapsedProjects((current) => ({
...current,
[project.id]: !current[project.id],
}))
}
data-project-header
className={css({ className={css({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
padding: "10px 8px 4px", padding: "10px 8px 4px",
gap: "8px", gap: "8px",
cursor: "pointer",
userSelect: "none",
":hover": { opacity: 0.8 },
})} })}
> >
<LabelSmall <div className={css({ display: "flex", alignItems: "center", gap: "4px", overflow: "hidden" })}>
color={theme.colors.contentSecondary} <div className={css({ position: "relative", width: "14px", height: "14px", flexShrink: 0 })}>
$style={{ <span
fontSize: "11px", className={css({
fontWeight: 700, position: "absolute",
letterSpacing: "0.05em", inset: 0,
textTransform: "uppercase", display: "inline-flex",
overflow: "hidden", alignItems: "center",
textOverflow: "ellipsis", justifyContent: "center",
whiteSpace: "nowrap", borderRadius: "3px",
}} fontSize: "9px",
> fontWeight: 700,
{project.label} lineHeight: 1,
</LabelSmall> color: "#fff",
<LabelXSmall color={theme.colors.contentTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> backgroundColor: projectIconColor(project.label),
})}
data-project-icon
>
{projectInitial(project.label)}
</span>
<span className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })} data-chevron>
{isCollapsed ? (
<ChevronDown size={12} color={theme.colors.contentTertiary} />
) : (
<ChevronUp size={12} color={theme.colors.contentTertiary} />
)}
</span>
</div>
<LabelSmall
color={theme.colors.contentSecondary}
$style={{
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{project.label}
</LabelSmall>
</div>
<LabelXSmall color={theme.colors.contentTertiary}>
{formatRelativeAge(project.updatedAtMs)}
</LabelXSmall>
</div> </div>
{project.handoffs.slice(0, visibleCount).map((handoff) => { {!isCollapsed && project.handoffs.map((handoff) => {
const isActive = handoff.id === activeId; const isActive = handoff.id === activeId;
const isDim = handoff.status === "archived"; const isDim = handoff.status === "archived";
const isRunning = handoff.tabs.some((tab) => tab.status === "running"); const isRunning = handoff.tabs.some((tab) => tab.status === "running");
@ -103,7 +171,7 @@ export const Sidebar = memo(function Sidebar({
onClick={() => onSelect(handoff.id)} onClick={() => onSelect(handoff.id)}
onContextMenu={(event) => onContextMenu={(event) =>
contextMenu.open(event, [ contextMenu.open(event, [
{ label: "Rename handoff", onClick: () => onRenameHandoff(handoff.id) }, { label: "Rename task", onClick: () => onRenameHandoff(handoff.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(handoff.id) }, { label: "Rename branch", onClick: () => onRenameBranch(handoff.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) }, { label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) },
]) ])
@ -111,13 +179,12 @@ export const Sidebar = memo(function Sidebar({
className={css({ className={css({
padding: "12px", padding: "12px",
borderRadius: "8px", borderRadius: "8px",
border: isActive ? "1px solid rgba(255, 255, 255, 0.2)" : "1px solid transparent", border: "1px solid transparent",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent", backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer", cursor: "pointer",
transition: "all 200ms ease", transition: "all 200ms ease",
":hover": { ":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)", backgroundColor: "rgba(255, 255, 255, 0.06)",
borderColor: theme.colors.borderOpaque,
}, },
})} })}
> >
@ -184,27 +251,6 @@ export const Sidebar = memo(function Sidebar({
); );
})} })}
{hiddenCount > 0 ? (
<button
type="button"
onClick={() =>
setExpandedProjects((current) => ({
...current,
[project.id]: true,
}))
}
className={css({
all: "unset",
padding: "8px 12px 10px 34px",
color: theme.colors.contentSecondary,
fontSize: "12px",
cursor: "pointer",
":hover": { color: theme.colors.contentPrimary },
})}
>
Show {hiddenCount} more
</button>
) : null}
</div> </div>
); );
})} })}

View file

@ -1,7 +1,7 @@
import { memo } from "react"; import { memo } from "react";
import { useStyletron } from "baseui"; import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography"; import { LabelSmall } from "baseui/typography";
import { MailOpen } from "lucide-react"; import { Clock, MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui"; import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Handoff } from "./view-model"; import { type AgentTab, type Handoff } from "./view-model";
@ -46,7 +46,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
}} }}
className={css({ className={css({
all: "unset", all: "unset",
fontWeight: 600, fontWeight: 500,
fontSize: "14px", fontSize: "14px",
color: theme.colors.contentPrimary, color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)", borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
@ -58,7 +58,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
<LabelSmall <LabelSmall
title="Rename" title="Rename"
color={theme.colors.contentPrimary} color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }} $style={{ fontWeight: 500, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", handoff.title)} onClick={() => onStartEditingField("title", handoff.title)}
> >
{handoff.title} {handoff.title}
@ -113,6 +113,24 @@ export const TranscriptHeader = memo(function TranscriptHeader({
) )
) : null} ) : null}
<div className={css({ flex: 1 })} /> <div className={css({ flex: 1 })} />
<div
className={css({
display: "flex",
alignItems: "center",
gap: "5px",
padding: "3px 10px",
borderRadius: "6px",
backgroundColor: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.08)",
fontSize: "11px",
fontWeight: 500,
color: theme.colors.contentSecondary,
whiteSpace: "nowrap",
})}
>
<Clock size={11} />
<span>847 min used</span>
</div>
{activeTab ? ( {activeTab ? (
<button <button
onClick={() => onSetActiveTabUnread(!activeTab.unread)} onClick={() => onSetActiveTabUnread(!activeTab.unread)}

View file

@ -86,7 +86,7 @@ const DetailRail = styled("aside", ({ $theme }) => ({
const FILTER_OPTIONS: SelectItem[] = [ const FILTER_OPTIONS: SelectItem[] = [
{ id: "active", label: "Active + Unmapped" }, { id: "active", label: "Active + Unmapped" },
{ id: "archived", label: "Archived Handoffs" }, { id: "archived", label: "Archived Tasks" },
{ id: "unmapped", label: "Unmapped Only" }, { id: "unmapped", label: "Unmapped Only" },
{ id: "all", label: "All Branches" }, { id: "all", label: "All Branches" },
]; ];
@ -394,7 +394,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
refetchInterval: 2_500, refetchInterval: 2_500,
queryFn: async () => { queryFn: async () => {
if (!selectedHandoffId) { if (!selectedHandoffId) {
throw new Error("No handoff"); throw new Error("No task selected");
} }
return backendClient.getHandoff(workspaceId, selectedHandoffId); return backendClient.getHandoff(workspaceId, selectedHandoffId);
}, },
@ -532,7 +532,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
if (!selectedForSession || !activeSandbox?.sandboxId) { if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff"); throw new Error("No sandbox is available for this task");
} }
return backendClient.createSandboxSession({ return backendClient.createSandboxSession({
workspaceId, workspaceId,
@ -565,7 +565,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const sendPrompt = useMutation({ const sendPrompt = useMutation({
mutationFn: async (prompt: string) => { mutationFn: async (prompt: string) => {
if (!selectedForSession || !activeSandbox?.sandboxId) { if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff"); throw new Error("No sandbox is available for this task");
} }
const sessionId = await ensureSessionForPrompt(); const sessionId = await ensureSessionForPrompt();
await backendClient.sendSandboxPrompt({ await backendClient.sendSandboxPrompt({
@ -834,7 +834,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
borderTop: `1px solid ${theme.colors.borderOpaque}`, borderTop: `1px solid ${theme.colors.borderOpaque}`,
})} })}
> >
<LabelXSmall color="contentSecondary">Handoffs</LabelXSmall> <LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
</div> </div>
</PanelHeader> </PanelHeader>
@ -845,7 +845,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
</> </>
) : null} ) : null}
{!handoffsQuery.isLoading && repoGroups.length === 0 ? <EmptyState>No repos or handoffs yet. Add a repo to start a workspace.</EmptyState> : null} {!handoffsQuery.isLoading && repoGroups.length === 0 ? (
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => ( {repoGroups.map((group) => (
<section <section
@ -960,7 +962,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}} }}
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`} data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
> >
Create Handoff Create Task
</Button> </Button>
</div> </div>
</section> </section>
@ -1172,7 +1174,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary"> <ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{formatRelativeAge(branch.updatedAt)} {formatRelativeAge(branch.updatedAt)}
</ParagraphSmall> </ParagraphSmall>
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>{branch.handoffId ? "handoff" : "unmapped"}</StatusPill> <StatusPill kind={branch.handoffId ? "positive" : "warning"}>
{branch.handoffId ? "task" : "unmapped"}
</StatusPill>
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null} {branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
</div> </div>
</div> </div>
@ -1264,7 +1268,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}} }}
data-testid={`repo-overview-create-${branchToken}`} data-testid={`repo-overview-create-${branchToken}`}
> >
Create Handoff Create Task
</Button> </Button>
) : null} ) : null}
@ -1302,7 +1306,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
> >
<Bot size={16} /> <Bot size={16} />
<HeadingXSmall marginTop="0" marginBottom="0"> <HeadingXSmall marginTop="0" marginBottom="0">
{selectedForSession ? (selectedForSession.title ?? "Determining title...") : "No handoff selected"} {selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
</HeadingXSmall> </HeadingXSmall>
{selectedForSession ? <StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill> : null} {selectedForSession ? <StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill> : null}
</div> </div>
@ -1333,7 +1337,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})} })}
> >
{!selectedForSession ? ( {!selectedForSession ? (
<EmptyState>Select a handoff from the left sidebar.</EmptyState> <EmptyState>Select a task from the left sidebar.</EmptyState>
) : ( ) : (
<> <>
<div <div
@ -1409,12 +1413,12 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
: !activeSandbox?.sandboxId : !activeSandbox?.sandboxId
? selectedForSession.statusMessage ? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}` ? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This handoff is still provisioning its sandbox." : "This task is still provisioning its sandbox."
: staleSessionId : staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.` ? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId : resolvedSessionId
? "No transcript events yet. Send a prompt to start this session." ? "No transcript events yet. Send a prompt to start this session."
: "No active session for this handoff."} : "No active session for this task."}
</EmptyState> </EmptyState>
) : null} ) : null}
@ -1525,7 +1529,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<DetailRail> <DetailRail>
<PanelHeader> <PanelHeader>
<HeadingSmall marginTop="0" marginBottom="0"> <HeadingSmall marginTop="0" marginBottom="0">
{repoOverviewMode ? "Repo Details" : "Handoff Details"} {repoOverviewMode ? "Repo Details" : "Task Details"}
</HeadingSmall> </HeadingSmall>
</PanelHeader> </PanelHeader>
@ -1577,7 +1581,10 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<MetaRow label="Parent" value={selectedBranchOverview.parentBranch ?? "-"} mono /> <MetaRow label="Parent" value={selectedBranchOverview.parentBranch ?? "-"} mono />
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono /> <MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} /> <MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
<MetaRow label="Handoff" value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"} /> <MetaRow
label="Task"
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
/>
</div> </div>
)} )}
</section> </section>
@ -1585,7 +1592,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
) )
) : !selectedForSession ? ( ) : !selectedForSession ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary"> <ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No handoff selected. No task selected.
</ParagraphSmall> </ParagraphSmall>
) : ( ) : (
<> <>
@ -1601,7 +1608,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
gap: theme.sizing.scale300, gap: theme.sizing.scale300,
})} })}
> >
<MetaRow label="Handoff" value={selectedForSession.handoffId} mono /> <MetaRow label="Task" value={selectedForSession.handoffId} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono /> <MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono /> <MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div> </div>
@ -1728,7 +1735,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}} }}
overrides={modalOverrides} overrides={modalOverrides}
> >
<ModalHeader>Create Handoff</ModalHeader> <ModalHeader>Create Task</ModalHeader>
<ModalBody> <ModalBody>
<div <div
className={css({ className={css({
@ -1738,7 +1745,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})} })}
> >
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary"> <ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Pick a repo, describe the task, and the backend will create a handoff. Pick a repo, describe the task, and the backend will create a task.
</ParagraphSmall> </ParagraphSmall>
<div> <div>
@ -1876,7 +1883,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}} }}
data-testid="handoff-create-submit" data-testid="handoff-create-submit"
> >
Create Handoff Create Task
</Button> </Button>
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -1510,10 +1510,11 @@
} }
.message.assistant .message-content { .message.assistant .message-content {
background: var(--surface); background: none;
border: 1px solid var(--border); border: none;
color: var(--text-secondary); color: var(--text-secondary);
border-bottom-left-radius: 4px; border-radius: 0;
padding: 0 16px;
} }
.message.system .avatar { .message.system .avatar {

View file

@ -26,6 +26,7 @@ export interface ChatComposerProps {
rows?: number; rows?: number;
textareaProps?: Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "className" | "disabled" | "onChange" | "onKeyDown" | "placeholder" | "rows" | "value">; textareaProps?: Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "className" | "disabled" | "onChange" | "onKeyDown" | "placeholder" | "rows" | "value">;
renderSubmitContent?: () => ReactNode; renderSubmitContent?: () => ReactNode;
renderFooter?: () => ReactNode;
} }
const DEFAULT_CLASS_NAMES: ChatComposerClassNames = { const DEFAULT_CLASS_NAMES: ChatComposerClassNames = {
@ -62,6 +63,7 @@ export const ChatComposer = ({
rows = 1, rows = 1,
textareaProps, textareaProps,
renderSubmitContent, renderSubmitContent,
renderFooter,
}: ChatComposerProps) => { }: ChatComposerProps) => {
const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides); const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides);
const isSubmitDisabled = disabled || submitDisabled || (!allowEmptySubmit && message.trim().length === 0); const isSubmitDisabled = disabled || submitDisabled || (!allowEmptySubmit && message.trim().length === 0);
@ -92,6 +94,7 @@ export const ChatComposer = ({
rows={rows} rows={rows}
disabled={disabled} disabled={disabled}
/> />
{renderFooter?.()}
<button <button
type="submit" type="submit"
className={resolvedClassNames.submit} className={resolvedClassNames.submit}