mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
feat(desktop): add detachable Bestie chat
Signed-off-by: cynfria <yescynthia@gmail.com>
This commit is contained in:
@@ -1009,6 +1009,102 @@ function MessageComposer({ compact = false }: { compact?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BestieChat({ onClose }: { onClose: () => void }) {
|
||||
const [detached, setDetached] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const dragRef = useRef({
|
||||
pointerId: -1,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
beganDetached: false,
|
||||
});
|
||||
|
||||
const beginDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
if ((event.target as Element).closest("button, input")) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
originX: rect.left,
|
||||
originY: rect.top,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
beganDetached: detached,
|
||||
};
|
||||
panel.style.setProperty("--bestie-x", `${rect.left}px`);
|
||||
panel.style.setProperty("--bestie-y", `${rect.top}px`);
|
||||
panel.classList.add("is-dragging");
|
||||
};
|
||||
|
||||
const updateDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!panel || drag.pointerId !== event.pointerId) return;
|
||||
const pullDistance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
|
||||
drag.x = Math.max(16, Math.min(window.innerWidth - panel.offsetWidth - 16, drag.originX + event.clientX - drag.startX));
|
||||
drag.y = Math.max(74, Math.min(window.innerHeight - panel.offsetHeight - 16, drag.originY + event.clientY - drag.startY));
|
||||
panel.style.setProperty("--bestie-x", `${drag.x}px`);
|
||||
panel.style.setProperty("--bestie-y", `${drag.y}px`);
|
||||
panel.classList.toggle("is-tearing", !drag.beganDetached && pullDistance > 48);
|
||||
};
|
||||
|
||||
const endDrag = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
const drag = dragRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!panel || drag.pointerId !== event.pointerId) return;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
const shouldDetach = drag.beganDetached || Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 84;
|
||||
panel.classList.remove("is-dragging", "is-tearing");
|
||||
if (shouldDetach) {
|
||||
panel.classList.add("is-detached");
|
||||
setDetached(true);
|
||||
setPosition({ x: drag.x, y: drag.y });
|
||||
} else {
|
||||
panel.style.removeProperty("--bestie-x");
|
||||
panel.style.removeProperty("--bestie-y");
|
||||
}
|
||||
drag.pointerId = -1;
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={`bestie-chat${detached ? " is-detached" : ""}`}
|
||||
role="dialog"
|
||||
aria-label="Chat with Bestie"
|
||||
style={detached ? ({ "--bestie-x": `${position.x}px`, "--bestie-y": `${position.y}px` } as CSSProperties) : undefined}
|
||||
>
|
||||
<header
|
||||
className="bestie-chat-header"
|
||||
onPointerDown={beginDrag}
|
||||
onPointerMove={updateDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div><img src="/snek.png" alt="" /><strong>Bestie</strong></div>
|
||||
<button type="button" aria-label="Close Bestie chat" onClick={onClose}><X aria-hidden="true" size={18} strokeWidth={1.7} /></button>
|
||||
</header>
|
||||
<div className="bestie-chat-content">
|
||||
<div className="bestie-message is-bestie"><img src="/snek.png" alt="" /><p>Hi! What are you working through?</p></div>
|
||||
<div className="bestie-message is-user"><p>I want to make these panels feel more composable.</p></div>
|
||||
<div className="bestie-message is-bestie"><img src="/snek.png" alt="" /><p>I can help with that. Drag me into the workspace when you want to keep this chat around.</p></div>
|
||||
</div>
|
||||
<div className="bestie-composer"><input aria-label="Message Bestie" placeholder="How can Bestie help?" /><button type="button" aria-label="Send to Bestie"><ArrowUp aria-hidden="true" size={18} strokeWidth={1.8} /></button></div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesView() {
|
||||
const [layouts, setLayouts] = useState(INITIAL_MESSAGE_LAYOUT);
|
||||
const [connections, setConnections] = useState(INITIAL_MESSAGE_CONNECTIONS);
|
||||
@@ -1304,6 +1400,7 @@ function MessagesView() {
|
||||
export function BuzzProjectPrototype() {
|
||||
const [activeTab, setActiveTab] = useState<"me" | "messages" | "projects">("me");
|
||||
const [selectedTask, setSelectedTask] = useState<SelectedTask | null>(null);
|
||||
const [bestieOpen, setBestieOpen] = useState(false);
|
||||
const appShellRef = useRef<HTMLElement>(null);
|
||||
const projectWorldRef = useRef<HTMLDivElement>(null);
|
||||
const canvasPanRef = useRef({
|
||||
@@ -1389,9 +1486,11 @@ export function BuzzProjectPrototype() {
|
||||
<nav className="segmented" aria-label="Primary navigation">
|
||||
<button type="button" className={activeTab === "me" ? "selected" : ""} onClick={() => showTab("me")}>Me</button><button type="button" className={activeTab === "messages" ? "selected" : ""} onClick={() => showTab("messages")}>Messages</button><button type="button" className={activeTab === "projects" ? "selected" : ""} onClick={() => showTab("projects")}>Projects</button><button type="button" className="add-button" aria-label="Create new"><Plus aria-hidden="true" size={18} strokeWidth={1.75} /></button>
|
||||
</nav>
|
||||
<div className="utilities"><button type="button" className="round-button" aria-label="Search"><Search className="search-icon" aria-hidden="true" size={18} strokeWidth={1.75} /></button><div className="avatar" aria-label="Cynthia"><span>C</span></div></div>
|
||||
<div className="utilities"><button type="button" className={`round-button snake-button${bestieOpen ? " is-active" : ""}`} aria-label="Chat with Bestie" aria-expanded={bestieOpen} onClick={() => setBestieOpen((open) => !open)}><img src="/snek.png" alt="" /></button><button type="button" className="round-button" aria-label="Search"><Search className="search-icon" aria-hidden="true" size={18} strokeWidth={1.75} /></button><div className="avatar" aria-label="Cynthia"><span>C</span></div></div>
|
||||
</header>
|
||||
|
||||
{bestieOpen ? <BestieChat onClose={() => setBestieOpen(false)} /> : null}
|
||||
|
||||
{activeTab === "projects" ? (
|
||||
<ProjectsView />
|
||||
) : activeTab === "messages" ? (
|
||||
|
||||
@@ -47,10 +47,65 @@ body { background: #e6f2f5; color: var(--ink); font-family: Inter, ui-sans-serif
|
||||
.segmented .add-button { width: 36px; padding: 0; display: grid; place-items: center; }
|
||||
.utilities { justify-self: end; display: flex; align-items: center; gap: 12px; }
|
||||
.round-button { width: var(--header-control-size); height: var(--header-control-size); border-radius: 50%; display: grid; place-items: center; border: 1px solid rgba(255,255,255,.9); background: rgba(244,249,251,.52); cursor: pointer; backdrop-filter: blur(16px); }
|
||||
.snake-button img { display: block; width: 30px; height: 30px; object-fit: contain; }
|
||||
.snake-button.is-active { background: rgba(255,255,255,.78); box-shadow: 0 8px 22px rgba(54,82,94,.12); }
|
||||
.search-icon { display: block; width: 18px; height: 18px; }
|
||||
.avatar { width: var(--header-control-size); height: var(--header-control-size); border-radius: 50%; display: grid; place-items: center; color: rgba(255,255,255,.95); background: radial-gradient(circle at 53% 29%, #d8af86 0 13%, transparent 14%), radial-gradient(ellipse at 50% 68%, #3f2923 0 25%, transparent 26%), linear-gradient(145deg, #99a65d, #49352d 65%, #c1a16e); border: 2px solid rgba(255,255,255,.95); box-shadow: 0 0 0 1px rgba(44,52,45,.08); font-size: 11px; font-weight: 650; }
|
||||
.avatar span { transform: translateY(1px); text-shadow: 0 1px 3px rgba(0,0,0,.38); }
|
||||
|
||||
.bestie-chat {
|
||||
--bestie-x: 0px;
|
||||
--bestie-y: 0px;
|
||||
position: fixed;
|
||||
z-index: 45;
|
||||
top: 74px;
|
||||
right: 20px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
height: 430px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,.92);
|
||||
border-radius: 28px;
|
||||
background: rgba(255,255,255,.82);
|
||||
box-shadow: 0 18px 50px rgba(53,76,86,.16), inset 0 1px 0 rgba(255,255,255,.8);
|
||||
backdrop-filter: blur(24px) saturate(118%);
|
||||
animation: bestie-popover-enter 180ms cubic-bezier(.215,.61,.355,1);
|
||||
transform-origin: calc(100% - 21px) -28px;
|
||||
}
|
||||
.bestie-chat.is-dragging,
|
||||
.bestie-chat.is-detached {
|
||||
top: 0;
|
||||
right: auto;
|
||||
left: 0;
|
||||
transform: translate3d(var(--bestie-x), var(--bestie-y), 0) scale(1);
|
||||
animation: none;
|
||||
will-change: transform;
|
||||
}
|
||||
.bestie-chat.is-detached { z-index: 44; background: rgba(255,255,255,.9); transition: transform 240ms cubic-bezier(.455,.03,.515,.955), box-shadow 180ms ease; }
|
||||
.bestie-chat.is-dragging { transition: none; }
|
||||
.bestie-chat.is-tearing { transform: translate3d(var(--bestie-x), var(--bestie-y), 0) scale(1.018); box-shadow: 0 24px 64px rgba(53,76,86,.22), inset 0 1px 0 rgba(255,255,255,.9); }
|
||||
.bestie-chat-header { display: flex; height: 58px; align-items: center; justify-content: space-between; padding: 0 14px 0 16px; border-bottom: 1px solid rgba(38,38,38,.08); cursor: grab; touch-action: none; user-select: none; }
|
||||
.bestie-chat.is-dragging .bestie-chat-header { cursor: grabbing; }
|
||||
.bestie-chat-header > div { display: flex; align-items: center; gap: 9px; }
|
||||
.bestie-chat-header img { width: 28px; height: 28px; object-fit: contain; }
|
||||
.bestie-chat-header strong { font-size: 14px; font-weight: 650; }
|
||||
.bestie-chat-header button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 50%; background: transparent; cursor: pointer; }
|
||||
.bestie-chat-header button:hover { background: rgba(38,38,38,.055); }
|
||||
.bestie-chat-content { height: calc(100% - 144px); overflow: auto; padding: 20px 16px; }
|
||||
.bestie-message { display: flex; align-items: flex-end; gap: 8px; margin-bottom: 14px; }
|
||||
.bestie-message img { width: 28px; height: 28px; flex: 0 0 auto; object-fit: contain; }
|
||||
.bestie-message p { max-width: 82%; margin: 0; padding: 10px 13px; border-radius: 17px; background: rgba(238,238,238,.86); font-size: 14px; line-height: 1.35; }
|
||||
.bestie-message.is-user { justify-content: flex-end; }
|
||||
.bestie-message.is-user p { color: #fff; background: #1688cf; }
|
||||
.bestie-composer { position: absolute; right: 14px; bottom: 14px; left: 14px; display: flex; height: 58px; align-items: center; gap: 10px; padding: 0 10px 0 16px; border: 1px solid rgba(38,38,38,.12); border-radius: 18px; background: rgba(255,255,255,.82); box-shadow: 0 7px 22px rgba(57,78,87,.05); }
|
||||
.bestie-composer input { min-width: 0; flex: 1; border: 0; outline: 0; color: #262626; background: transparent; font-size: 14px; }
|
||||
.bestie-composer input::placeholder { color: rgba(38,38,38,.4); }
|
||||
.bestie-composer button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 50%; background: #f3e7ff; cursor: pointer; }
|
||||
|
||||
@keyframes bestie-popover-enter {
|
||||
from { opacity: 0; transform: translateY(-6px) scale(.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.project-stage { position: relative; min-height: 100svh; overflow: hidden; touch-action: none; user-select: none; cursor: grab; }
|
||||
.project-stage.is-panning { cursor: grabbing; }
|
||||
.project-world { position: absolute; inset: 0; transform: translate3d(0,0,0); transition: transform 520ms cubic-bezier(.22,1,.36,1); will-change: transform; }
|
||||
@@ -464,6 +519,8 @@ body { background: #e6f2f5; color: var(--ink); font-family: Inter, ui-sans-serif
|
||||
.orbit-system, .task-detail-panel { transition-duration: 1ms; animation-duration: 1ms; }
|
||||
.message-panel { transition: none; }
|
||||
.merge-edge-indicator { transition: none; }
|
||||
.bestie-chat { animation: none; }
|
||||
.bestie-chat.is-detached { transition: none; }
|
||||
.response-composer { transition: none; }
|
||||
.response-composer textarea { animation: none; }
|
||||
.project-name-pill { transition: none; }
|
||||
|
||||
BIN
prototypes/project-cube-orbit/public/snek.png
Normal file
BIN
prototypes/project-cube-orbit/public/snek.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Reference in New Issue
Block a user