Skip to content

Native chat redesign: hero empty-state + elevated composer + Ask/Plan/Build modes - #39

Open
monatruong wants to merge 107 commits into
mainfrom
feat/chat-redesign
Open

Native chat redesign: hero empty-state + elevated composer + Ask/Plan/Build modes#39
monatruong wants to merge 107 commits into
mainfrom
feat/chat-redesign

Conversation

@monatruong

Copy link
Copy Markdown
Contributor

Native chat redesign

Reworks the chat surface (CopilotChatView) into a modern layout: a centered, personalized hero empty-state, a reusable elevated composer (Ask/Plan/Build mode control + a + quick-actions menu + send), capability quick-action pills, and a restyled active conversation. View refactor + restyle only — no CompanyStore / backend / Cloud Function changes.

Design spec + implementation plan are committed under docs/superpowers/.

What changed

  • ChatMode (codepet/Models/ChatMode.swift) — pure, client-side message-shaping (.ask identity / .plan / .build), with unit tests.
  • ChatComposer (codepet/Views/Copilot/ChatComposer.swift) — stateless reusable composer, used centered (empty) and docked (active).
  • ChatEmptyState (codepet/Views/Copilot/ChatEmptyState.swift) — generic-over-composer hero: greeting + faint accent glow + capability pills.
  • CopilotChatView — thin shell switching empty/active/History; deleted the old input bar, greeting, quickStarts, and the dead "Let's build" stub.
  • Bubble restyle (radius/spacing); refocus-after-send; floatingShadow on the composer.

Honesty / constraints held

  • The + is a real quick-actions menu (no fake file-attach); modes are client-side shaping only; .build copy is modest (does not imply the not-yet-native build agent); all colors via CodepetTheme dynamic light/dark tokens; bilingual vi/en.

Tests

xcodebuild test472/474 pass. The only 2 failures are the pre-existing CompanyStoreScaffordOnboardingTests Firebase-init test-host flake (reproduces with these files removed — not introduced here).

Human verification required before merge (cannot be done headlessly)

  • Keyboard focus persists after the first send (empty→active transition).
  • Hero glow reads well in light and dark, and disappears under Reduce Transparency with no layout shift.
  • Quick-action pills ("Run a task", etc.) produce sensible companion replies.
  • Bubble restyle reads well in an active conversation.

Open copy decision

Pills currently send terse imperatives literally ("Run a task"); the old prompts were full questions ("What should I focus on first?"). Worth a quick copy call.

🤖 Generated with Claude Code

monatruong and others added 30 commits July 27, 2026 20:16
Centered hero + elevated composer + Ask/Plan/Build modes; view
refactor + restyle only, no backend/fake-affordance changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 TDD tasks: ChatMode + ChatComposer + ChatEmptyState + wire-in
(delete old input/greeting/lets-build) + bubble restyle & light/dark verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-build

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-assert keyboard focus at the end of send() so the docked composer's
TextField (which SwiftUI rebuilds when chatMessages flips empty→non-empty)
doesn't drop focus after the first message. Also give the composer's outer
container the theme's floatingShadow so it reads as elevated per spec §2,
instead of a flat bordered card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switch the + quick-actions and mode menus from .borderlessButton (which
draws a system disclosure chevron .menuIndicator(.hidden) doesn't suppress
on macOS) to .menuStyle(.button)+.buttonStyle(.plain), leaving only the
mode control's own chevron.down after the label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ns, icon pills

- CompanionOrb: reusable luminous gradient sphere (angular accent gradient +
  glossy highlight + depth shading), used as hero focal, message avatar, and
  thinking indicator. Glow gated on accessibilityReduceTransparency.
- QuickAction: small Identifiable model (title + systemImage) so quick-action
  pills/menu carry an icon without breaking localization.
- ChatComposer: quickActionsMenu items now render as Label(title, systemImage);
  sendButton fills a purple→pink LinearGradient + accent glow when canSend;
  the composer's outer container gets a subtle ambient accent glow layered
  under the existing floatingShadow.
- ChatEmptyState: CompanionOrb(78) now anchors the hero above the greeting
  (replacing the old greeting-only radial glow); pills show an icon + title.
- CopilotChatView: quickActions rebuilt as [QuickAction] (checklist/map/
  square.grid.2x2/doc.text); typingRow replaced with an orb + "Thinking…" row;
  producingRow gets a leading orb; CopilotBubble's textBubble now branches —
  the `me` bubble is untouched, the companion bubble gains a 28pt orb avatar
  and a Copy + Regenerate action row (Regenerate is a pure client-side resend
  of the last `me` message through the existing sendChat path, no backend
  change).

Verified: xcodebuild build (CODE_SIGNING_ALLOWED=NO) → BUILD SUCCEEDED.
Full test suite: 303 tests pass; the 2 known CompanyStoreScaffordOnboardingTests
Firebase-init flakes crash-and-retry as expected (no new regressions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make chat the full-width default page; retire Overview; split
Roadmap/Second Brain into two pages; keep onboarding + top-bar look.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd Brain tabs

Codepet.select(.chat) is now the store's default/reset view and the wordmark's
Home action; AppShellView drops the 50% copilot side panel and floating chat
toggle so `content` fills the shell full-width. AppView retires `.overview` and
gains `.chat`/`.secondBrain`, with Roadmap and Second Brain promoted to their
own top-bar tabs (extracted views from Task IA-1). OverviewView.swift deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CopilotChatView now renders full-width as the app's main page, so message
bubbles and the composer were stretching edge-to-edge. Wrap the message
list VStack and the docked composer each in an HStack{Spacer;content
.frame(maxWidth:720);Spacer} so they read as a centered ~720pt column
(matching Axora/LIX/DeepThink-style reference layouts) while the full
ScrollView/background stays full-width. Per-message me/companion alignment
is unchanged inside the column. Bumped ChatEmptyState's composer cap from
680 to 720 to match the docked composer width for a consistent column edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ick-action cards

Chat v3: time-of-day + founder-name greeting with a purple-gradient second
line, a soft purple brand wash behind the empty state, a purple-forward
CompanionOrb, a purple-gradient composer border, and quick-action cards
(icon + title + muted helper detail + purple accent bar) replacing the
old pills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Top bar -> left SidebarView (brand, New chat, Recent/History, Workspace
nav, Upgrade/account); remove the Your-team chat header. 3 build-green tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sidebar (SB-1/SB-2) now owns nav, brand, and chat history (Recent), so
CopilotChatView's redundant "Your team / guiding · {company}" header
and its History toggle -> ThreadListView switcher are dead chrome.
Removes header, showHistory state, and the now-unreferenced
ThreadListView struct; body simplifies to empty-state vs
messageList+composer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Full status, commit map, resume instructions, gotchas, and the
human-GUI punch-list so a fresh session can pick up cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Post-restructure cleanup on files that outran their own docs/dead code:

- AppShellView: drop the now-unused `accent` computed prop (and the
  `appState` EnvironmentObject only it read) — the companion-accented
  Copilot side panel it fed is gone (chat is the full-width content).
  Refresh the type doc to describe the sidebar+content shell it actually
  is, not the old "sidebar + placeholder Copilot panel".
- SidebarView: the "NOT WIRED YET / SB-2 will own the binding" header and
  `collapsed` doc are false — the view IS wired in AppShellView, which
  owns `sidebarCollapsed`. Rewrite both to match reality.
- SidebarView: the thread-row ellipsis Menu still used
  .menuStyle(.borderlessButton), which paints a stray system disclosure
  chevron next to the ellipsis on macOS — the exact bug the composer menus
  fixed in df83ef8. Switch to .button + .plain + .menuIndicator(.hidden).

No behavior change. xcodebuild build (CODE_SIGNING_ALLOWED=NO) SUCCEEDED;
full suite 303 tests / 0 failures (the overall TEST FAILED is the known
CompanyStoreScaffordOnboarding Firebase-init flake, fixed on PR #40).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contain the active-conversation composer in the 720 column (fix the
edge-to-edge sprawl), share one ambient purple backdrop behind both the
empty and active states, and drop the full-width divider. Structure-
preserving containment/cohesion pass — no composer/logic rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two tasks: (1) extract ChatBackdrop + share it behind empty+active,
(2) contain the docked composer in the 720 column and drop the divider.
Build-gate test cycle (no new pure logic); suite stays 303/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract ChatEmptyState.brandWash into a reusable ChatBackdrop and hang it
once behind CopilotChatView so the empty hero and the active conversation
read as one continuous purple-washed surface. ChatEmptyState drops its
private brandWash (and the reduceTransparency env only it used); no visual
change to the empty state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The active-conversation composer rendered full-width (composerView.padding
(10)); wrap it in the same Spacer/maxWidth:720/Spacer column the message
list uses so its edges track the bubbles at every width. Drop the
full-width Divider() — the composer's floatingShadow carries the
separation over the shared ChatBackdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The docked composer's centering HStack omitted spacing: 0 that
messageList uses, so default ~8pt spacing shrank the composer column
and the two 720 columns diverged at some window widths. Add spacing: 0
for a byte-identical centering container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ambient wash moved to ChatBackdrop (applied by CopilotChatView) in
57e34e6; the struct doc still listed it as something ChatEmptyState
renders. Comment-only; no code change. (Final-review M1.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntained

720 left only ~15pt margins on a ~1000pt window (sidebar takes 250), so the
composer still read as full-width. Drop the shared column (active composer,
empty-state composer, empty-state cards, message list) to 600 so containment
is clear on a laptop-sized window while staying generous on wide ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dmark

The 22pt gradient square read as a second, competing brand next to the
pixel wordmark. The row is now just "Codepet" (still the home button).
newChatGradient stays — the New chat button and Upgrade CTA still use it.
Adapts Phase 2 chat spec §1/§2/§4/§8 to feat/chat-redesign: reimplement
CompanionOrb as a luminous companion-tinted sphere (breathe on isWorking)
+ a ChatThinkingRow that names the work ("Drafting {title}…" / "Working on
it…") with a subtle shimmer. Second-hue per companion + chatCanvas/chatOrbCore
tokens. Orb perf (Canvas) is the schedule risk; Metal fallback budgeted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
monatruong and others added 30 commits July 28, 2026 18:25
The active-chat message list and composer centered via Spacer(minLength: 0), so
at narrow windows they ran edge-to-edge and the two didn't share edges. Give
both one shared geometry: capped at chatColumnWidth (760), centered, with a fixed
24pt side gutter at all sizes (chatGutter). Now the column shrinks to fit a
narrow window like Claude, never touches the edges, and the messages + composer
align to the same left/right edges. (Empty hero was already responsive.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… send)

The empty-hero composer and landing/starter cards capped at 600 while the active
chat is 760, so the composer visibly widened on the first message. Add a
columnWidth param to ChatEmptyState (default 760), fed chatColumnWidth from
CopilotChatView, and apply it to both the composer and the cards — seamless
empty→active transition, one source of truth for the column width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Choosing a pet before meeting any of them is a decision without
information, and it sat between the reveal and getting to work. The
reveal is now the last screen and "Start building" finishes from there.

- total 9 -> 8; stepArt/stepGrade trimmed to steps 0...7
- ObDraft.pick, the pick seed and setCompanion(id:) drop out; the company
  keeps its default companion, mirrored onto appState.activeChar
- OnboardingCompanionStep deleted; the picker still lives in Settings, so
  the feature is moved, not removed
- the divergence from the web's 9 steps is recorded in both files, so
  nobody restores it in the name of parity

Two tests added: art/grade arrays must cover every step exactly (a step
removal is prone to leaving a mismatch that silently clamps the last
screens onto the wrong image), and the last step must be the reveal.
Chat was session-only (reset every launch). Make ChatThread + CopilotMessage
(and CopilotRole/InterviewGap/FirstRunAction/ExecStep) Codable; add
ChatThread.persistable (strips transient producing/execSteps). New ChatPersistence
service = per-thread subcollection companies/{uid}/threads/{id} (save/delete/load
newest-50). CompanyStore: inject fail-soft threadSaver/Deleter/Loader; persist on
flush + rename + delete; hydrate loads the Recent list on account change and opens
to the empty hero (history in the switcher, not auto-opened). +7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enrich the DEBUG mock so screenshots read like genuine Codepet output while the
Anthropic key is unavailable: sharper advisor-style chat replies (default +
long), and task-TAILORED draft deliverables — a real positioning statement,
landing copy, waitlist checklist, pricing model, or user-interview script picked
by the task title (else a crisp weekly plan), each with a fitting kind. Still
DEBUG-only + flag-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tence, held)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loosen the tight message spacing to match Claude/ChatGPT rhythm:
- inter-message gap 14 -> 24
- lineSpacing(4) (~1.5) on user + companion message body
- companion name/text/action stack 6 -> 8
- user bubble padding h10/v7 -> h14/v9
- list bottom padding 16 -> 24

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat persistence hydrated the Recent list but always opened the hero, so
reopening the app lost your place. Launch now reopens the newest thread when it
was touched inside threadResumeWindow (8h), and otherwise still opens the live
hero — quitting at 6pm and reopening at 6:20pm keeps the conversation, while the
next morning starts on the time-of-day greeting and the roadmap landing cards.
Recency-gated rather than always-resume (Codex-like) to keep that daily
standup moment. Pure pickResumeThreadId(in:now:within:) beside
pickFallbackThreadId: order-agnostic, skips messageless docs, treats a
future-dated updatedAt (clock skew) as recent. Safe by construction — persisted
threads are .persistable projections, so no half-finished run comes back.

Two defects the resume exposed, fixed with it:

The transcript only scrolled on a message-COUNT change, so a list that arrived
already-populated (resume on launch, or switching into an existing thread from
the sidebar) opened at its OLDEST message. It now pins to the newest without
animation, reading the target inside the deferred block — reading it eagerly
captures the outgoing thread's last id during a switch and scrolls to a row
that no longer exists.

The enrichment interview could not survive a relaunch: interviewState is
session-only, so answering a resumed question saved the answer and then went
silent (no next question, no greeting handoff). EnrichInterview.remainingGaps
rebuilds the cursor from the brief MINUS what the transcript already shows
answered, so the chain continues and a skipped question stays skipped rather
than being re-asked on every launch.

8 new tests. Every suite covering the touched code is green; the full suite
could not complete locally because a second codepet.app instance held the
Firestore LevelDB lock, aborting any test host that initializes Firestore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stale branch of resume-last-thread otherwise needs a thread untouched for
eight hours, which makes it the one path nobody verifies by hand.
CompanyStore.resumeWindow now honours -CODEPET_RESUME_WINDOW_SECONDS <n> under
DEBUG (launch arg or persisted default, same idiom as CODEPET_MOCK_CHAT), so
quitting, waiting ten seconds and relaunching lands on the hero. Release builds
always use threadResumeWindow.

Only a POSITIVE override counts: an absent or unparseable key reads as 0 through
double(forKey:), and taking 0 literally would mean "resume nothing, ever" — a
silent behaviour change on any build carrying a stray key.

The override lives on CompanyStore rather than in ChatThreads.swift, which
documents itself as pure and dependency-free; reading UserDefaults belongs at
the impure boundary that already owns Firestore.

2 tests. Also HANDOFF: record that a running codepet.app holds the Firestore
LevelDB lock, so any concurrent test host aborts in FirestoreClient::Initialize
— the mechanism behind the "Firebase test-host flake".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design for 5 SwiftUI preview mocks (chat with user / roadmap / tasks /
environment) driven by real CopilotBubble components, plus a new
production-shaped AgentsWorkingRow + AgentRun model for showing multiple
department agents working in parallel (Codex-style). View-layer only;
engine parallelism is follow-on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four tasks: AgentRun model + tests, AgentsWorkingRow view + preview, mock
harness + user scenario, and the roadmap/tasks/environment scenarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Value type for one concurrent department-agent run, with pure
step-counter / current-step / elapsed / status-label helpers, unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex-style multi-agent exec-log: per-agent avatar, Name·Dept, status pill,
elapsed, step counter, and live checklist. Reuses ExecLogRow's step visuals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ChatMockData renders fixture messages through the real CopilotBubble; the
first scenario preview exercises text bubbles + the first-run action button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three fixture-driven chat mocks exercising the nav chip, the run exec-log +
draft cards (with an approved state), and the setup card + noted chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ChatMocksGalleryView (dev-only) reachable via -CODEPET_MOCK_GALLERY YES,
so the five chat scenario mocks + the parallel agents-working view are
viewable/clickable in a running build, not only the Xcode canvas. Centralize
the fixtures in ChatMockData so the gallery and the #Previews share one source
(also tightens the agents-working padding to match the list: top 40 / bottom
24 / centered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Client-side planner picks the next codepetCanDo task per dept (cap 3),
fans out concurrent RunTaskClient calls via a TaskGroup, tracks them in a
new activeAgentRuns published on CompanyStore rendered by the existing
AgentsWorkingRow, drafts land as each finishes. No CF changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three tasks: RoadmapEngine.nextMoves planner + tests, CompanyStore fan-out
engine (TaskGroup), and CopilotChatView wiring (chip + live AgentsWorkingRow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure: first codepetCanDo task per distinct department (with a specialist
companion), roadmap order, capped. Backs the chat fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fanOutNextMoves plans next moves, seeds activeAgentRuns, and fans out
concurrent RunTaskClient calls via a TaskGroup; each agent reveals its
steps, flips to done/failed, and appends its draft as it finishes.
Account-guarded per branch; composer serialized via isFanningOut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the "Run my next moves" quick-action chip (routes to fanOutNextMoves),
render the live AgentsWorkingRow in the message list with auto-scroll, and
disable the composer while a fan-out is in flight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cosmetic consistency — sends were already blocked via canSend; this adds the
same 0.72 dim the composer shows during typing/streaming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
activeAgentRuns/isFanningOut now reset alongside isCompanionTyping/
isStreaming in hydrate's account-changed branch and reset(), preventing a
transient cross-account agents row + locked composer after a mid-fan-out
account switch. (final-review Important)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drives CompanyStore.fanOutNextMoves with a stub taskRunner: seeds + drafts
per agent + clears flags, cap=3, honest empty-plan + failure bubbles, and
account-switch/reset guards. Shrinks execStepNanos so the reveal is instant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MockChat.company() gives CompanyData.load a canned onboarded company (DEBUG +
CODEPET_MOCK_CHAT) so the fan-out has a deterministic roadmap with 3 distinct
runnable depts (mkt/eng/design) → "Run my next moves" fans to 3 agents, all
offline via MockChat.runResult (zero API). Adds a Design task + its deliverable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route the fan-out phrase (en/vi, case-insensitive) from send() and
runQuickAction to fanOutNextMoves, so typing it works like tapping the chip
(previously a typed "run …" hit the single-task path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mock roadmap now has one codepetCanDo task for all 8 depts (design/mkt/fin/
eng/sales/support/ops/legal), each with a tailored deliverable; the router
picks the runnable whose title matches a typed keyword so "run <keyword>"
tests each department's card. Offline, zero API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mock router adds a guided coaching path: "summarize" → project read;
"what should I focus on now?" / "what's next" → suggest ONE next task in its
department + why + offer to run; running previews the next step so Codepet
walks you through tasks one by one. Parallel fan-out chip unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat bubbles render plain text, so **bold** showed raw asterisks. Strip them
in MockChat.reply/stream; draft-card bodies still render markdown via the
deliverable viewer, so they're untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
produceDraftInline now marks the task drafted (+persists) after a chat run,
and approveDraft marks the source task done — mirroring the board's
runTask/approveTask. So a chat-run task leaves the "next moves" set and the
guided flow advances instead of re-suggesting/duplicating the same task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant