feat: integrate mobile work chat and Gitea delivery

This commit is contained in:
sai karthik
2026-07-26 00:50:11 +05:30
parent 48200a11df
commit 2a0487aa6e
31 changed files with 2477 additions and 220 deletions

View File

@@ -2,7 +2,9 @@ export {
MobileAssistantChatScreen,
MobileExpandedWorkScreen,
MobileHomeScreen,
MobileWorkChatScreen,
MobileWorkListScreen,
MobileWorkStackScreen,
MobileWorkUnitDetailScreen,
} from "./mobile-workspace/index";
export type {
@@ -12,6 +14,8 @@ export type {
MobileSignalView,
MobileWorkspaceScreenProps,
MobileWorkspaceView,
MobileWorkChatScreenProps,
MobileWorkStackScreenProps,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./mobile-workspace/index";

View File

@@ -1,7 +1,13 @@
export { MobileAssistantChatScreen } from "./mobile-assistant-chat-screen";
export { MobileExpandedWorkScreen } from "./mobile-expanded-work-screen";
export { MobileHomeScreen } from "./mobile-home-screen";
export { MobileSettingsPanel } from "./mobile-settings-panel";
export type { MobileSettingsPanelProps } from "./mobile-settings-panel";
export { MobileWorkChatScreen } from "./mobile-work-chat-screen";
export type { MobileWorkChatScreenProps } from "./mobile-work-chat-screen";
export { MobileWorkListScreen } from "./mobile-work-list-screen";
export { MobileWorkStackScreen } from "./mobile-work-stack-screen";
export type { MobileWorkStackScreenProps } from "./mobile-work-stack-screen";
export { MobileWorkUnitDetailScreen } from "./mobile-work-unit-detail-screen";
export type {
MobileAssistantMessageView,

View File

@@ -0,0 +1,83 @@
import { X } from "lucide-react";
export interface MobileSettingsPanelProps {
readonly modelId: string;
readonly modelLabel: string;
readonly onClose: () => void;
readonly onSignOut?: () => void;
readonly userEmail?: string;
readonly userName?: string;
}
export const MobileSettingsPanel = ({
modelId,
modelLabel,
onClose,
onSignOut,
userEmail,
userName,
}: MobileSettingsPanelProps) => (
<section
aria-label="Settings"
className="absolute inset-0 z-50 flex flex-col bg-[#f7f8f5] text-[#11110f]"
>
<header className="flex h-20 shrink-0 items-center border-b border-[#e9eae6] bg-white px-4">
<div>
<h2 className="text-[18px] font-semibold">Settings</h2>
<p className="text-[11px] text-[#807b76]">
Your account and Zopu runtime.
</p>
</div>
<button
aria-label="Close settings"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onClose}
type="button"
>
<X className="size-5" />
</button>
</header>
<div className="space-y-3 p-4">
<div className="rounded-[22px] bg-white p-4">
<p className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase">
Account
</p>
<p className="mt-2 text-[15px] font-semibold">
{userName ?? "Signed-in user"}
</p>
{userEmail ? (
<p className="mt-0.5 text-[12px] text-[#807b76]">{userEmail}</p>
) : null}
</div>
<div className="rounded-[22px] bg-white p-4">
<label
className="text-[10px] font-semibold tracking-[0.08em] text-[#807b76] uppercase"
htmlFor="mobile-stack-model"
>
Chat model
</label>
<select
className="mt-2 h-11 w-full rounded-[14px] bg-[#f2f3ef] px-3 text-[13px] font-semibold outline-none"
disabled
id="mobile-stack-model"
value={modelId}
>
<option value={modelId}>{modelLabel}</option>
</select>
<p className="mt-2 text-[11px] leading-4 text-[#807b76]">
The configured OpenRouter model is used for Zopu conversations and
project work.
</p>
</div>
{onSignOut ? (
<button
className="h-12 w-full rounded-[16px] bg-black text-[13px] font-semibold text-white"
onClick={onSignOut}
type="button"
>
Sign out
</button>
) : null}
</div>
</section>
);

View File

@@ -0,0 +1,206 @@
import { cn } from "@code/ui/lib/utils";
import { ArrowLeft, ArrowUp, ExternalLink, Sparkles } from "lucide-react";
import type { FormEvent } from "react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "../ai-elements/conversation";
import {
Message,
MessageContent,
MessageResponse,
} from "../ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
} from "../ai-elements/prompt-input";
import type { MobileWorkspaceView } from "./models";
export interface MobileWorkChatScreenProps {
readonly composerValue: string;
readonly data: MobileWorkspaceView;
readonly onBack: () => void;
readonly onComposerChange: (value: string) => void;
readonly onComposerMessageSubmit?: (message: string) => Promise<void>;
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly statusMessage?: string;
}
export const MobileWorkChatScreen = ({
composerValue,
data,
onBack,
onComposerChange,
onComposerMessageSubmit,
onComposerSubmit,
statusMessage,
}: MobileWorkChatScreenProps) => {
const workUnit = data.selectedWorkUnit;
return (
<div className="mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
<header className="flex h-20 shrink-0 items-center border-b border-[#eff0ec] bg-white px-4">
<button
aria-label="Back to daily focus"
className="flex size-10 items-center justify-center rounded-full bg-[#f2f3ef]"
onClick={onBack}
type="button"
>
<ArrowLeft className="size-5" />
</button>
<div className="min-w-0 flex-1 px-3 text-center">
<p className="truncate text-[15px] font-semibold">
{workUnit?.title ?? "Work chat"}
</p>
<p className="text-[10px] text-[#807b76]">
{workUnit
? `${workUnit.code} · ${workUnit.statusLabel}`
: "Loading work unit"}
</p>
</div>
{workUnit?.reviewUrl ? (
<a
aria-label={`Open pull request ${workUnit.pullRequestNumber ?? ""}`}
className="flex size-10 items-center justify-center rounded-full bg-[#c8ff00]"
href={workUnit.reviewUrl}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-5" />
</a>
) : (
<span className="size-10" />
)}
</header>
<main className="min-h-0 flex-1 bg-[#fefefe]">
<Conversation className="h-full">
<ConversationContent className="min-h-full gap-5 px-4 pt-5 pb-6">
{workUnit ? (
<section className="rounded-[22px] bg-[#e8efff] p-4 text-[#14265f]">
<div className="flex items-center text-[10px] font-semibold text-[#315dc0]">
{workUnit.statusLabel.toUpperCase()}
<span className="ml-auto">{workUnit.progress}%</span>
</div>
<p className="mt-2 text-[17px] leading-5 font-semibold">
{workUnit.nextAction}
</p>
<p className="mt-1 line-clamp-2 text-[12px] leading-4 opacity-70">
{workUnit.summary}
</p>
{workUnit.reviewUrl ? (
<a
className="mt-4 flex h-10 items-center justify-center gap-2 rounded-[14px] bg-black text-[12px] font-semibold text-white"
href={workUnit.reviewUrl}
rel="noreferrer"
target="_blank"
>
Review PR #{workUnit.pullRequestNumber}
<ExternalLink className="size-4" />
</a>
) : null}
</section>
) : null}
<Message className="max-w-full gap-0" from="assistant">
<MessageContent className="w-full bg-transparent p-0 text-[#11110f] dark:text-[#11110f]">
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
<Sparkles className="size-4" />
</span>
<strong className="text-[14px]">Zopu</strong>
<span className="rounded-full bg-[#f1f1ef] px-2 py-1 text-[9px] text-[#aaa7a3]">
WORK PARTNER
</span>
</div>
<p className="mt-3 text-[15px] leading-5">
This chat is scoped to {workUnit?.title ?? "this work unit"}.
Add context, change direction, or ask what happens next.
</p>
</MessageContent>
</Message>
{data.assistant.messages.map((message) => (
<Message
className="max-w-full gap-0"
from={message.role}
key={message.id}
>
<MessageContent
className={cn(
"max-w-full gap-0 overflow-visible p-0",
message.role === "user"
? "ml-auto w-fit max-w-[86%] rounded-[22px] bg-[#0c0c0b] px-4 py-3 text-[15px] leading-5 text-white group-[.is-user]:rounded-[22px] group-[.is-user]:bg-[#0c0c0b] group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-white"
: "w-full bg-transparent text-[#11110f] dark:text-[#11110f]"
)}
>
{message.role === "assistant" ? (
<>
<div className="flex items-center gap-2">
<span className="flex size-7 items-center justify-center rounded-[9px] bg-[#c8ff00]">
<Sparkles className="size-4" />
</span>
<strong className="text-[14px]">Zopu</strong>
</div>
<MessageResponse
className="mt-3 text-[15px] leading-5 text-[#11110f] dark:text-[#11110f]"
isAnimating={data.assistant.isBusy}
>
{message.text}
</MessageResponse>
</>
) : (
<span className="whitespace-pre-wrap">{message.text}</span>
)}
</MessageContent>
</Message>
))}
{data.assistant.isBusy ? (
<p className="text-[12px] text-[#807b76]">Zopu is working</p>
) : null}
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-2 size-9 rounded-full border border-[#dededb] bg-white text-black shadow-sm"
/>
</Conversation>
</main>
<footer className="relative h-[106px] shrink-0 border-t border-[#eff0ec] bg-white px-3 pt-3">
<PromptInput
className="w-full [&_[data-slot=input-group]]:min-h-[58px] [&_[data-slot=input-group]]:rounded-full [&_[data-slot=input-group]]:border-0 [&_[data-slot=input-group]]:bg-[#f2f3ef] [&_[data-slot=input-group]]:px-3 [&_[data-slot=input-group]]:py-1.5 [&_[data-slot=input-group]]:shadow-none [&_[data-slot=input-group]]:dark:bg-[#f2f3ef]"
onSubmit={async (message) => {
if (onComposerMessageSubmit) {
await onComposerMessageSubmit(message.text);
return;
}
onComposerChange(message.text);
}}
>
<PromptInputBody>
<PromptInputTextarea
aria-label="Ask about this work unit"
className="max-h-20 min-h-10 flex-1 resize-none bg-transparent px-2 py-2.5 text-[14px] leading-5 shadow-none outline-none placeholder:text-[#aaa7a3] dark:bg-transparent"
onChange={(event) => onComposerChange(event.currentTarget.value)}
placeholder="Ask about this work unit..."
value={composerValue}
/>
<PromptInputSubmit
aria-label="Send"
className="size-11 shrink-0 rounded-full bg-black text-white hover:bg-black/85 disabled:bg-black disabled:text-white disabled:opacity-100"
disabled={!composerValue.trim() || data.assistant.isBusy}
size="icon-sm"
status={data.assistant.isBusy ? "streaming" : "ready"}
>
<ArrowUp className="size-5" />
</PromptInputSubmit>
</PromptInputBody>
</PromptInput>
<p className="mt-1 text-center text-[9px] text-[#b5b2ae]">
{statusMessage ?? "Messages stay with this work unit"}
</p>
<form className="sr-only" onSubmit={onComposerSubmit} />
</footer>
</div>
);
};

View File

@@ -0,0 +1,458 @@
import { useSwipeCard } from "@code/ui/hooks/use-swipe-card";
import { cn } from "@code/ui/lib/utils";
import {
ArrowUp,
Check,
ChevronRight,
MoreHorizontal,
Plus,
RotateCcw,
} from "lucide-react";
import type { FormEvent, MouseEvent } from "react";
import { MobileSettingsPanel } from "./mobile-settings-panel";
import type {
MobileWorkspaceView,
MobileWorkUnitTone,
MobileWorkUnitView,
} from "./models";
const toneClass: Readonly<Record<MobileWorkUnitTone, string>> = {
blue: "bg-[#e8efff] text-[#14265f]",
green: "bg-[#e8f7ef] text-[#105c35]",
orange: "bg-[#fff0e8] text-[#8a2e13]",
purple: "bg-[#f1e8ff] text-[#3e0b74]",
};
const statusClass: Readonly<Record<MobileWorkUnitTone, string>> = {
blue: "bg-[#d5e1ff] text-[#315dc0]",
green: "bg-[#d8f1e4] text-[#238050]",
orange: "bg-[#ffe0d0] text-[#c53c08]",
purple: "bg-[#e6d6ff] text-[#7137ef]",
};
const dateLabel = new Intl.DateTimeFormat("en", {
day: "numeric",
weekday: "short",
})
.format(new Date())
.toUpperCase();
interface WorkStackHeaderProps {
readonly activeCount: number;
readonly onOpenSettings?: () => void;
readonly userInitials: string;
}
const WorkStackHeader = ({
activeCount,
onOpenSettings,
userInitials,
}: WorkStackHeaderProps) => (
<header className="flex h-[96px] shrink-0 items-center bg-white px-4">
<div className="flex size-10 items-center justify-center rounded-[14px] bg-[#c8ff00] text-[20px] font-bold text-black">
Z
</div>
<div className="ml-3">
<h1 className="text-[20px] leading-6 font-semibold tracking-[-0.03em] text-[#0b0b0a]">
Zopu
</h1>
<p className="mt-0.5 flex items-center gap-2 text-[13px] leading-4 text-[#7c7772]">
<span className="size-1.5 rounded-full bg-[#64ad1f]" />
{activeCount} active work units
</p>
</div>
<button
aria-label="Open settings"
className="ml-auto flex size-10 items-center justify-center rounded-full bg-[#f2f3ef] text-black"
onClick={onOpenSettings}
type="button"
>
<MoreHorizontal className="size-5" />
</button>
<button
aria-label="Open profile"
className="ml-2 flex size-10 items-center justify-center rounded-full bg-[#0b0b0a] text-[13px] font-semibold text-white"
onClick={onOpenSettings}
type="button"
>
{userInitials}
</button>
</header>
);
interface SwipeWorkCardProps {
readonly data: MobileWorkspaceView;
readonly onChecked: (workUnitId: string) => void;
readonly onOpen: (workUnitId: string) => void;
readonly onSendBack: (workUnitId: string) => void;
readonly workUnit: MobileWorkUnitView;
}
const SwipeWorkCard = ({
data,
onChecked,
onOpen,
onSendBack,
workUnit,
}: SwipeWorkCardProps) => {
const swipeCard = useSwipeCard({
itemKey: workUnit.id,
onSwipe: (direction) => {
if (direction === "right") {
onChecked(workUnit.id);
return;
}
onSendBack(workUnit.id);
},
});
const {
backLabelRef,
cardRef,
checkedLabelRef,
dragged,
pointerHandlers,
swipe,
transitionHandlers,
} = swipeCard;
const handleOpen = (event: MouseEvent<HTMLButtonElement>) => {
if (dragged()) {
event.preventDefault();
return;
}
onOpen(workUnit.id);
};
return (
<div className="relative h-full touch-pan-y select-none">
<span
className="pointer-events-none absolute top-5 left-5 z-10 rounded-full bg-black px-3 py-1.5 text-[11px] font-semibold text-white opacity-0"
ref={backLabelRef}
>
SEND TO BACK
</span>
<span
className="pointer-events-none absolute top-5 right-5 z-10 rounded-full bg-[#c8ff00] px-3 py-1.5 text-[11px] font-semibold text-black opacity-0"
ref={checkedLabelRef}
>
CHECKED
</span>
<article
aria-label={`${workUnit.title}. Swipe right to check, left to send to back.`}
className={cn(
"relative h-full overflow-hidden rounded-[30px] p-5 shadow-[0_12px_38px_rgba(43,61,107,0.08)] will-change-transform",
toneClass[workUnit.tone]
)}
ref={cardRef}
{...transitionHandlers}
>
<div className="flex items-center">
<span
className={cn(
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
statusClass[workUnit.tone]
)}
>
{workUnit.statusLabel.toUpperCase()}
</span>
<span className="ml-2 text-[10px] opacity-55">{workUnit.code}</span>
<MoreHorizontal className="ml-auto size-5 opacity-75" />
</div>
<button
className="mt-5 block w-full text-left focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#315dc0]"
onClick={handleOpen}
onKeyDown={(event) => {
if (event.key === "ArrowRight") {
swipe("right");
}
if (event.key === "ArrowLeft") {
swipe("left");
}
}}
type="button"
{...pointerHandlers}
>
<h3 className="line-clamp-2 text-[27px] leading-[30px] font-semibold tracking-[-0.045em]">
{workUnit.title}
</h3>
<p className="mt-2 truncate text-[13px] opacity-60">
Updated {workUnit.updatedLabel}
</p>
<div className="mt-3 border-t border-current/10 pt-3">
<p className="text-[10px] font-semibold opacity-60">
CURRENT STATE
</p>
<p className="mt-1 line-clamp-2 text-[14px] leading-5 opacity-80">
{workUnit.summary}
</p>
</div>
<div className="mt-5 h-[7px] rounded-full bg-current/10">
<div
className="h-full rounded-full bg-current"
style={{ width: `${workUnit.progress}%` }}
/>
</div>
<div className="mt-2 flex text-[10px] font-semibold">
<span>{workUnit.progress}% COMPLETE</span>
<span className="ml-auto font-normal opacity-60">
{workUnit.canRetry ? "Needs attention" : "On track"}
</span>
</div>
</button>
<div className="absolute inset-x-5 bottom-5 flex items-center border-t border-current/10 pt-4">
<button
aria-label={`Mark ${workUnit.title} checked`}
className="flex size-10 shrink-0 items-center justify-center rounded-[14px] bg-white text-[#315dc0] active:scale-[0.98]"
onClick={() => swipe("right")}
type="button"
>
<Check className="size-5" />
</button>
<div className="ml-3 min-w-0 flex-1">
<p className="text-[10px] font-semibold opacity-55">NEXT ACTION</p>
<p className="truncate text-[14px] font-semibold">
{workUnit.nextAction}
</p>
<p className="text-[10px] opacity-55">
{data.artifactCount} artifacts available
</p>
</div>
<button
className="ml-3 flex h-12 shrink-0 items-center gap-1 rounded-[17px] bg-[#c8ff00] px-4 text-[12px] font-semibold text-black active:scale-[0.98]"
onClick={() => onOpen(workUnit.id)}
type="button"
>
Open unit
<ChevronRight className="size-4" />
</button>
</div>
</article>
</div>
);
};
interface WorkStackComposerProps {
readonly onChange: (value: string) => void;
readonly onSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly statusMessage?: string;
readonly value: string;
}
const WorkStackComposer = ({
onChange,
onSubmit,
statusMessage,
value,
}: WorkStackComposerProps) => (
<footer className="relative h-[112px] shrink-0 border-t border-[#eff0ec] bg-white px-[14px] pt-[14px]">
<form
className="flex h-[60px] items-center rounded-[30px] bg-[#f2f3ef] p-2"
onSubmit={onSubmit}
>
<button
aria-label="Add attachment"
className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white text-[#5e5a56]"
type="button"
>
<Plus className="size-5" strokeWidth={1.8} />
</button>
<label className="ml-2 min-w-0 flex-1">
<span className="sr-only">Ask Zopu or create new work</span>
<textarea
className="block h-5 w-full resize-none overflow-hidden bg-transparent text-[14px] leading-5 text-[#282622] outline-none placeholder:text-[#b3afab]"
onChange={(event) => onChange(event.target.value)}
placeholder="Ask Zopu or create new work..."
rows={1}
value={value}
/>
<span className="block truncate text-[10px] leading-4 text-[#c0bcb7]">
{statusMessage ?? "Use @ to reference a work unit"}
</span>
</label>
<button
aria-label="Send"
className="ml-2 flex size-11 shrink-0 items-center justify-center rounded-full bg-black text-white active:scale-[0.98]"
type="submit"
>
<ArrowUp className="size-5" strokeWidth={2.2} />
</button>
</form>
<span className="absolute bottom-[10px] left-1/2 h-[5px] w-[120px] -translate-x-1/2 rounded-full bg-black" />
</footer>
);
export interface MobileWorkStackScreenProps {
readonly checkedCount: number;
readonly composerValue: string;
readonly data: MobileWorkspaceView;
readonly modelId: string;
readonly modelLabel: string;
readonly onComposerChange: (value: string) => void;
readonly onComposerSubmit: (event: FormEvent<HTMLFormElement>) => void;
readonly onOpenUnit: (workUnitId: string) => void;
readonly onRestoreChecked: () => void;
readonly onSettingsClose: () => void;
readonly onSettingsOpen: () => void;
readonly onSignOut?: () => void;
readonly onWorkUnitChecked: (workUnitId: string) => void;
readonly onWorkUnitSentBack: (workUnitId: string) => void;
readonly settingsOpen: boolean;
readonly statusMessage?: string;
readonly userEmail?: string;
readonly userInitials: string;
readonly userName?: string;
readonly workUnits: readonly MobileWorkUnitView[];
}
export const MobileWorkStackScreen = ({
checkedCount,
composerValue,
data,
modelId,
modelLabel,
onComposerChange,
onComposerSubmit,
onOpenUnit,
onRestoreChecked,
onSettingsClose,
onSettingsOpen,
onSignOut,
onWorkUnitChecked,
onWorkUnitSentBack,
settingsOpen,
statusMessage,
userEmail,
userInitials,
userName,
workUnits,
}: MobileWorkStackScreenProps) => {
const [currentWorkUnit, ...queuedWorkUnits] = workUnits;
const previewWorkUnits = queuedWorkUnits.slice(0, 2).toReversed();
const attentionLabel =
data.needsAttentionCount === 1
? "1 item needs attention."
: `${data.needsAttentionCount} items need attention.`;
return (
<div className="relative mx-auto flex h-[100dvh] max-h-[858px] min-h-[700px] w-full max-w-[390px] flex-col overflow-hidden bg-white text-[#0b0b0a]">
<WorkStackHeader
activeCount={data.activeCount}
onOpenSettings={onSettingsOpen}
userInitials={userInitials}
/>
<main className="relative min-h-0 flex-1 overflow-hidden bg-[#f7f8f5] px-5 pt-[26px]">
<div className="flex items-center">
<h2 className="text-[31px] leading-9 font-semibold tracking-[-0.055em]">
Daily focus
</h2>
<span className="ml-auto rounded-full bg-white px-3 py-1.5 text-[11px] font-semibold text-[#5f5b57]">
{dateLabel}
</span>
</div>
<p className="mt-1 text-[14px] leading-5 text-[#807b76]">
{data.needsAttentionCount > 0
? `One priority is moving. ${attentionLabel}`
: "Your active work is moving."}
</p>
<section
aria-label="Active work stack"
className="absolute inset-x-5 top-[154px] bottom-11"
>
{previewWorkUnits.map((workUnit, reverseIndex) => {
const depth = previewWorkUnits.length - reverseIndex;
return (
<button
aria-label={`Open ${workUnit.title}`}
className={cn(
"absolute inset-x-5 h-[112px] rounded-[28px] px-4 pt-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black",
toneClass[workUnit.tone]
)}
key={workUnit.id}
onClick={() => onOpenUnit(workUnit.id)}
style={{
top: `${(depth - 1) * 36}px`,
transform: `scale(${1 - depth * 0.025})`,
}}
type="button"
>
<span
className={cn(
"rounded-full px-2.5 py-1.5 text-[10px] font-semibold",
statusClass[workUnit.tone]
)}
>
{workUnit.statusLabel.toUpperCase()}
</span>
<span className="ml-2 text-[10px] opacity-55">
{workUnit.code}
</span>
</button>
);
})}
{currentWorkUnit ? (
<div
className="absolute inset-x-0 bottom-0 h-[calc(100%-58px)]"
key={currentWorkUnit.id}
>
<SwipeWorkCard
data={data}
onChecked={onWorkUnitChecked}
onOpen={onOpenUnit}
onSendBack={onWorkUnitSentBack}
workUnit={currentWorkUnit}
/>
</div>
) : (
<div className="absolute inset-x-0 top-16 rounded-[30px] bg-white px-6 py-12 text-center">
<Check className="mx-auto size-8 text-[#64ad1f]" />
<h3 className="mt-3 text-[20px] font-semibold">
Daily focus cleared
</h3>
<p className="mt-1 text-[13px] text-[#807b76]">
{checkedCount} work units checked for now.
</p>
<button
className="mt-5 inline-flex h-10 items-center gap-2 rounded-[14px] bg-black px-4 text-[12px] font-semibold text-white"
onClick={onRestoreChecked}
type="button"
>
<RotateCcw className="size-4" />
Restore stack
</button>
</div>
)}
</section>
{workUnits.length > 0 ? (
<div className="absolute inset-x-0 bottom-4 flex justify-center gap-2">
{workUnits.slice(0, 4).map((workUnit, index) => (
<span
className={cn(
"h-1.5 rounded-full bg-[#d7d6d4]",
index === 0 ? "w-6 bg-black" : "w-1.5"
)}
key={workUnit.id}
/>
))}
</div>
) : null}
</main>
<WorkStackComposer
onChange={onComposerChange}
onSubmit={onComposerSubmit}
statusMessage={statusMessage}
value={composerValue}
/>
{settingsOpen ? (
<MobileSettingsPanel
modelId={modelId}
modelLabel={modelLabel}
onClose={onSettingsClose}
onSignOut={onSignOut}
userEmail={userEmail}
userName={userName}
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,156 @@
import type {
PointerEvent as ReactPointerEvent,
TransitionEvent as ReactTransitionEvent,
} from "react";
import { useCallback, useEffect, useRef } from "react";
export type SwipeDirection = "left" | "right";
interface UseSwipeCardOptions {
readonly itemKey: string;
readonly onSwipe: (direction: SwipeDirection) => void;
}
const SWIPE_THRESHOLD = 82;
const EXIT_DISTANCE = 460;
const EXIT_DURATION_MS = 180;
export const useSwipeCard = ({ itemKey, onSwipe }: UseSwipeCardOptions) => {
const cardRef = useRef<HTMLElement>(null);
const checkedLabelRef = useRef<HTMLSpanElement>(null);
const backLabelRef = useRef<HTMLSpanElement>(null);
const startXRef = useRef(0);
const distanceRef = useRef(0);
const draggingRef = useRef(false);
const swipingRef = useRef(false);
const pendingDirectionRef = useRef<SwipeDirection | null>(null);
const exitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateVisuals = useCallback((distance: number, animate = false) => {
const card = cardRef.current;
if (!card) {
return;
}
card.style.transition = animate
? `transform ${EXIT_DURATION_MS}ms cubic-bezier(0.16, 1, 0.3, 1)`
: "none";
card.style.transform = `translate3d(${distance}px, 0, 0) rotate(${distance / 32}deg)`;
const actionOpacity = Math.min(Math.abs(distance) / SWIPE_THRESHOLD, 1);
if (checkedLabelRef.current) {
checkedLabelRef.current.style.opacity =
distance > 0 ? String(actionOpacity) : "0";
}
if (backLabelRef.current) {
backLabelRef.current.style.opacity =
distance < 0 ? String(actionOpacity) : "0";
}
}, []);
const reset = useCallback(() => {
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
distanceRef.current = 0;
draggingRef.current = false;
swipingRef.current = false;
pendingDirectionRef.current = null;
updateVisuals(0, true);
}, [updateVisuals]);
const finishSwipe = useCallback(() => {
const direction = pendingDirectionRef.current;
if (!direction) {
return;
}
pendingDirectionRef.current = null;
if (exitTimerRef.current) {
clearTimeout(exitTimerRef.current);
exitTimerRef.current = null;
}
onSwipe(direction);
}, [onSwipe]);
const swipe = useCallback(
(direction: SwipeDirection) => {
if (swipingRef.current) {
return;
}
swipingRef.current = true;
pendingDirectionRef.current = direction;
const distance = direction === "right" ? EXIT_DISTANCE : -EXIT_DISTANCE;
distanceRef.current = distance;
updateVisuals(distance, true);
exitTimerRef.current = setTimeout(finishSwipe, EXIT_DURATION_MS + 80);
},
[finishSwipe, updateVisuals]
);
useEffect(() => {
reset();
return reset;
}, [itemKey, reset]);
const onPointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (
swipingRef.current ||
(event.pointerType === "mouse" && event.button !== 0)
) {
return;
}
draggingRef.current = true;
startXRef.current = event.clientX;
distanceRef.current = 0;
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!draggingRef.current) {
return;
}
distanceRef.current = event.clientX - startXRef.current;
updateVisuals(distanceRef.current);
};
const onPointerUp = (event: ReactPointerEvent<HTMLElement>) => {
if (!draggingRef.current) {
return;
}
draggingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (Math.abs(distanceRef.current) >= SWIPE_THRESHOLD) {
swipe(distanceRef.current > 0 ? "right" : "left");
return;
}
reset();
};
const onTransitionEnd = (event: ReactTransitionEvent<HTMLElement>) => {
if (
event.currentTarget === event.target &&
event.propertyName === "transform" &&
swipingRef.current
) {
finishSwipe();
}
};
return {
backLabelRef,
cardRef,
checkedLabelRef,
dragged: () => Math.abs(distanceRef.current) > 8,
pointerHandlers: {
onPointerCancel: reset,
onPointerDown,
onPointerMove,
onPointerUp,
},
swipe,
transitionHandlers: {
onTransitionEnd,
},
} as const;
};