-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Expand file tree
/
Copy pathHomeView.tsx
More file actions
3658 lines (3541 loc) · 156 KB
/
Copy pathHomeView.tsx
File metadata and controls
3658 lines (3541 loc) · 156 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Composed Home view — the top-down layout the entry view renders
// when the left nav rail's "Home" tab is active.
//
// Owns the prompt state + active plugin lifecycle and stitches
// together the smaller pieces (HomeHero, RecentProjectsStrip,
// PluginsHomeSection). Replaces the older left-side `PluginLoopHome`
// surface by lifting its plugin orchestration up here so the prompt
// textarea can live centered in the hero.
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { Dialog, DialogFooter, DialogTitle } from '@open-design/components';
import type {
ApplyResult,
ChatSessionMode,
ConnectorDetail,
InputFieldSpec,
McpServerConfig,
InstalledPluginRecord,
LocalCatalogScope,
ProjectKind,
WorkspaceCollabContext,
WorkspaceProjectSummary,
AudioVoiceOption,
WorkspaceContextItem,
} from '@open-design/contracts';
import { DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID } from '@open-design/contracts';
import { projectKindFromMetadataToTracking } from '@open-design/contracts/analytics';
import { useAnalytics } from '../analytics/provider';
import {
trackCommunityGalleryClick,
trackHomeChatComposerClick,
trackPageView,
trackPluginDetailModalClick,
trackPluginDetailModalSharePopoverClick,
trackPluginDetailModalSurfaceView,
trackPluginReplacementModalClick,
trackPluginReplacementModalSurfaceView,
trackPluginReplacementResult,
trackRecentProjectsClick,
} from '../analytics/events';
import {
applyPlugin,
createProject,
duplicatePluginAsProject,
listPlugins,
listPluginsFresh,
pluginCatalogCacheKey,
readCachedVisiblePlugins,
patchProject,
resolvedWorkspaceContextForWrite,
ProjectCreateError,
renderPluginBriefTemplate,
resolvePluginQueryFallback,
} from '../state/projects';
import { FigmaImportModal } from './FigmaImportModal';
import { fetchMcpServers } from '../state/mcp';
import { takeHomeComposerAssetSeed } from '../state/libraryHandoff';
import { useI18n, useT } from '../i18n';
import {
formatModelWindowRetryAt,
modelWindowLimitCopy,
} from '../runtime/amr-guidance';
import {
localizeSkillName,
localizeSkillPrompt,
} from '../i18n/content';
import { fetchElevenLabsVoiceOptions } from '../providers/elevenlabs-voices';
import { IMAGE_MODELS } from '../media/models';
import {
mergeAihubmixImageModels,
useAIHubMixImageModels,
} from '../media/aihubmix-image-models';
import {
daemonIsLive,
dirExists,
fetchRecentLinkedDirs,
openFolderDialog,
pushRecentLinkedDir,
} from '../providers/registry';
import { isOpenDesignHostAvailable, pickHostWorkingDir } from '@open-design/host';
import type {
DesignSystemSummary,
Project,
ProjectMetadata,
PromptTemplateSummary,
SkillSummary,
} from '../types';
import { inlineMentionToken, mentionTokenPresent } from '../utils/inlineMentions';
import { smoothScrollToTop } from '../utils/smoothScrollToTop';
import {
missingRequiredInputs,
pluginInputsAreValid,
requiredInputsAreUserFillable,
} from '../utils/pluginRequiredInputs';
import { HomeHero, type ExamplePromptInfo, type HomeHeroHandle } from './HomeHero';
import { AppWashKineticGrid } from './AppWashKineticGrid';
import { findChip, HOME_HERO_CHIPS, type HomeHeroChip } from './home-hero/chips';
import {
prototypeSubChipForActionChipId,
prototypeSubChipForSlug,
type HomeHeroSubChip,
} from './home-hero/sub-chips';
import { homeHeroChipLabel } from './home-hero/chip-labels';
import type { PlaceholderScenario } from './home-hero/placeholderScenarios';
import { consumePendingHomeChip, HOME_CHIP_INTENT_EVENT } from '../runtime/home-intent';
import { navigate } from '../router';
import { setPendingDesignSystemCreateEntry } from '../analytics/ds-create-entry';
import { workspaceContextLinkedDirs } from './workspace-context';
import {
currentWorkspaceAccountGeneration,
useTeamProjects,
useWorkspaceContext,
workspaceResourceReadContext,
} from '../collab/useWorkspaceContext';
import { useWorkspaceInvalidation } from '../collab/workspace-events';
import { useWorkspaceSnapshotActivation } from '../collab/workspace-snapshot-activation';
import {
buildHomeMediaComposer,
homeMediaSurfaceForChipId,
metadataForHomeMediaComposer,
normalizeHomeMediaInputs,
type HomeComposerMediaSurface,
} from './home-hero/media-surfaces';
import {
buildPluginAuthoringInputs,
buildPluginAuthoringPromptForInputs,
PLUGIN_AUTHORING_PROMPT,
PLUGIN_AUTHORING_PROMPT_TEMPLATE,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { PluginDetailsModal } from './PluginDetailsModal';
import {
buildCommunityTemplates,
copyTemplatePrompt,
isPromptArtifact,
TemplatePreviewModal,
} from './CommunityTemplatePreview';
import { SkillDetailsModal } from './SkillDetailsModal';
import type { PluginLoopSubmit } from './PluginLoopHome';
import { localizePluginTitle } from './plugins-home/localization';
import type { PluginUseAction } from './plugins-home/useActions';
import { examplePresetSeedPrompt } from './plugins-home/presetSeedPrompt';
import { localizePluginDescription } from './plugins-home/localization';
import type { SharedProjectPredicate } from '../collab/all-projects-list';
import { RecentProjectsStrip } from './RecentProjectsStrip';
import type { Recommendation } from '../onboarding/recommendation';
import type { OnboardingEntry } from '../onboarding/onboarding-entry';
import { AnimatePresence } from 'motion/react';
import { DeepSeekV4FlashCampaign } from './DeepSeekV4FlashCampaign';
import type { DeepSeekV4FlashCampaignAudience } from '../campaigns/deepseek-v4-flash';
export interface ActivePlugin {
record: InstalledPluginRecord;
// `result` is `null` during the optimistic window — set on chip
// click before applyPlugin's roundtrip finishes — and is filled in
// once the daemon returns the snapshot + resolved context. submit()
// and contextItemCount both null-coalesce, so an in-flight active
// is safe to render without a result.
result: ApplyResult | null;
inputs: Record<string, unknown>;
inputFields: InputFieldSpec[];
inputsValid: boolean;
queryTemplate: string | null;
// True when `queryTemplate` covers only a suffix of the prompt (the plugin
// query appended after a user-owned draft), so input extraction must allow
// an arbitrary mutable prefix instead of anchoring at the start. Set by the
// use-with-query route.
queryTemplateAllowsPrefix?: boolean;
lastRenderedPrompt: string | null;
// Stage B of plugin-driven-flow-plan: when the user applied this
// plugin through the Home chip rail, the chip carries the project
// kind we should stamp on the resulting create payload. `null` =
// applied through the search picker / PluginsHomeSection, where the
// kind defaults to the historical 'prototype' value.
projectKind: ProjectKind | null;
chipId: string | null;
prototypeSubtypeId: string | null;
mediaSurface: HomeComposerMediaSurface | null;
projectMetadata: ProjectMetadata | null;
editableInputNames: string[];
preserveInputFields: boolean;
// True when the active plugin was bound through a type chip.
// In that mode we never push the rendered useCase.query into the
// textarea — the user keeps full control over the prompt and the
// plugin preset cards are the explicit opt-in for a starter
// sentence. Without this flag the media composer
// effect (which fires on external list reloads like ElevenLabs
// voices) and updateActiveInputs (fires on inline form edits)
// would back-fill the textarea, defeating the suppression that
// the chip click set up.
suppressPromptSync: boolean;
// True when the user explicitly picked THIS plugin — an example-prompt preset
// card or a Community card / detail modal — rather than a type chip binding
// its default plugin. Drives the active chip's clear (×) affordance. Persisted
// rather than re-derived from id equality, because a preset's plugin can
// legitimately equal the chip's default plugin id (e.g. the prototype rail's
// `example-web-prototype`).
explicitPick: boolean;
}
// `inlineBacked` distinguishes a context inserted as an inline `@mention` pill
// (added through the mention picker / plus menu, which writes a token into the
// prompt) from a context-only selection made through the plain `Use` action
// (which stages the context without touching the prompt). Inline-backed
// contexts are dropped once their `@` token is deleted; context-only ones stay
// selected until explicitly removed. Conflating the two drops plain `Use`
// selections from the submit payload because they never carry a token.
interface SelectedPluginContext {
record: InstalledPluginRecord;
inlineBacked: boolean;
}
interface SelectedMcpContext {
server: McpServerConfig;
inlineBacked: boolean;
}
interface SelectedConnectorContext {
connector: ConnectorDetail;
inlineBacked: boolean;
}
interface PendingReplacement {
title: string;
// Returns a promise resolving when the underlying plugin apply has
// finished (or rejecting on failure) so the modal's success/failure
// analytics fire on the real outcome, not on the synchronous
// queue-the-apply step.
confirm: () => Promise<void>;
// Plugin ids surrounding the replacement so the result event can
// report which plugin owned the existing prompt and which plugin is
// about to take over. `pluginBefore` is null when nothing was active
// (e.g. a manually typed prompt that should be replaced by a plugin
// selection).
pluginBefore: string | null;
pluginAfter: string;
}
interface PendingPluginUseHandoff {
pluginId: string;
action: PluginUseAction;
inputs?: Record<string, unknown>;
chipId?: string;
projectKind?: ProjectKind;
}
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
artifactKind: 'OpenDesign plugin',
audience: 'OpenDesign plugin authors',
topic: 'packaging a reusable workflow as an OpenDesign plugin',
};
interface Props {
isActive?: boolean;
projects: Project[];
projectsLoading?: boolean;
designSystems?: DesignSystemSummary[];
designSystemsLoading?: boolean;
defaultDesignSystemId?: string | null;
// `'blocked'` means the shell refused the submit but already surfaced its
// own UI (e.g. the AMR balance gate dialog): keep the draft, show no error.
onSubmit: (
payload: PluginLoopSubmit,
) => Promise<boolean | 'blocked' | void> | boolean | 'blocked' | void;
onOpenProject: (id: string, fileName?: string) => void;
onViewAllProjects: () => void;
onDeleteProject?: (id: string) => Promise<boolean | void> | boolean | void;
onDuplicateProject?: (id: string) => Promise<void> | void;
onRenameProject?: (id: string, name: string) => void;
onBrowseRegistry?: () => void;
onOpenIntegrations?: () => void;
onOpenMcp?: () => void;
// Stage B: optional callbacks the rail's migration chips need.
// HomeView itself never imports them; EntryShell threads them
// through so the dispatcher can stay declarative.
onOpenNewProject?: (tab: 'template') => void;
onStartBlankProject?: () => Promise<void> | void;
promptHandoff?: HomePromptHandoff | null;
/** The one shared-state answer for the home strip's cards. Owned by EntryShell
* because the SAME answer partitions its 全部项目 / 草稿 grids — a home share
* must move the project between those grids too, without a refetch. */
isSharedProject?: SharedProjectPredicate;
onProjectShared?: (project: WorkspaceProjectSummary) => void;
onProjectShareFailed?: (projectId: string) => void;
onProjectUnshared?: (projectId: string) => void;
/** Authoritative catalog owners plus any exact successful-move witness. */
projectOwnerMemberIds?: ReadonlyMap<string, string>;
skills?: SkillSummary[];
skillsLoading?: boolean;
connectors?: ConnectorDetail[];
promptTemplates?: PromptTemplateSummary[];
// Personalized first-run starting point (spec §7). Null unless the user just
// finished the About-you survey this session; EntryShell owns the state.
// Accepted for API compatibility but no longer rendered — see
// `recommendationSlot` below for why the strip was removed from Home.
recommendation?: Recommendation | null;
onRecommendationStart?: (input: {
name: string;
prompt: string;
metadata: ProjectMetadata;
onboardingEntry: OnboardingEntry;
}) => boolean | void | Promise<boolean | void>;
onRecommendationDismiss?: () => void;
executionSwitcher?: ReactNode;
artifactUpgradeSlot?: ReactNode;
deepSeekV4FlashCampaignAudience?: DeepSeekV4FlashCampaignAudience;
/** Real model switch for the campaign modal's paid 立即使用 CTA (D5).
* EntryShell owns the agent/model persistence callbacks; HomeView only
* threads them through, like the audience above. */
onDeepSeekV4FlashCampaignUseNow?: (agentId: string, modelId: string) => void;
/** Telemetry opt-in + install id for the modal's consent-gated AMR
* attribution — EntryShell reads them off config, HomeView threads. */
deepSeekV4FlashCampaignMetricsConsent?: boolean;
deepSeekV4FlashCampaignInstallationId?: string | null;
}
const EMPTY_DESIGN_SYSTEMS: DesignSystemSummary[] = [];
const EMPTY_SKILLS: SkillSummary[] = [];
const EMPTY_CONNECTORS: ConnectorDetail[] = [];
const EMPTY_PROMPT_TEMPLATES: PromptTemplateSummary[] = [];
// The Home composer lives inside EntryView, which App.tsx fully UNMOUNTS the
// moment the user opens a project tab (the single `appMain` slot swaps
// EntryView → ProjectView), and Settings — a standalone page, not a dialog —
// swaps `appMain` again the same way. Plain useState would therefore be
// discarded on every tab switch, so a half-typed prompt and the chosen design
// system vanish when the user steps away and comes back. Persist those two
// serializable, user-visible fields to localStorage so they survive the
// unmount/remount, mirroring ChatComposer's draft persistence. Object-valued
// selections (active template, skill, staged files, working directory) are
// intentionally NOT persisted here — they reference live catalogue records /
// File handles / a desktop auth token that cannot round-trip through JSON
// safely.
const HOME_COMPOSER_PROMPT_KEY = 'open-design:home-composer:prompt';
const HOME_COMPOSER_DESIGN_SYSTEM_KEY = 'open-design:home-composer:design-system';
const HOME_COMPOSER_DESIGN_SYSTEM_SCOPE_KEY = 'open-design:home-composer:design-system-scope';
// The active type-chip + bound plugin (the "创作类型" + "示例提示词" pick) is a
// third piece of composer state that used to fall through this same crack:
// `active` (below) held only a live `InstalledPluginRecord` + resolved apply
// result, neither of which survives JSON, so it was never persisted at all —
// a Settings round trip silently cleared the chip/example-prompt selection
// even though the prompt text and design system correctly came back. Persist
// only the serializable identity fields (chip id, Prototype subtype, plugin id,
// project kind) and re-resolve the full `ActivePlugin` from the live plugin catalog
// on remount (see `pendingChipRestore` below), the same way a cross-surface
// "use this plugin" hand-off resolves `pendingPluginUseHandoff`.
const HOME_COMPOSER_CHIP_KEY = 'open-design:home-composer:chip';
interface HomeComposerChipDraft {
chipId: string | null;
pluginId: string;
projectKind: ProjectKind | null;
prototypeSubtypeId?: string | null;
}
// `EntryShell` keeps `HomeView` permanently mounted and toggles it with CSS
// visibility instead of unmounting it on every Home/Community/... view
// switch (unlike the EntryView<->ProjectView swap described above, which
// still does a real unmount). The localStorage draft above is therefore
// read only once, on the very first mount, and a later `seedHomeComposerPrompt`
// call (from e.g. Community's "使用提示词"/Prompt button) has nothing left to
// remount into — it writes the draft key but nobody re-reads it. Dispatch a
// live event too so an already-mounted HomeView can pick up the seed
// directly; the draft key stays as the true-cold-mount fallback.
const HOME_COMPOSER_SEED_EVENT = 'open-design:home-composer:seed';
function readHomeComposerDraft(key: string): string | null {
if (typeof window === 'undefined') return null;
try {
return window.localStorage.getItem(key);
} catch {
return null;
}
}
function writeHomeComposerDraft(key: string, value: string | null): void {
if (typeof window === 'undefined') return;
try {
if (value) window.localStorage.setItem(key, value);
else window.localStorage.removeItem(key);
} catch {
// Storage unavailable (private mode / quota exceeded) — degrade silently to
// in-memory-only state; the composer still works for this session.
}
}
function localCatalogScopeFromWorkspaceContext(
context: WorkspaceCollabContext | null,
): LocalCatalogScope | null {
if (!context?.workspaceId?.trim() || !context.workspaceMemberId?.trim()) return null;
return {
workspaceId: context.workspaceId.trim(),
workspaceMemberId: context.workspaceMemberId.trim(),
};
}
function readLocalCatalogScopeDraft(key: string): LocalCatalogScope | null {
const raw = readHomeComposerDraft(key);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LocalCatalogScope> | null;
if (!parsed?.workspaceId?.trim() || !parsed.workspaceMemberId?.trim()) return null;
return {
workspaceId: parsed.workspaceId.trim(),
workspaceMemberId: parsed.workspaceMemberId.trim(),
};
} catch {
return null;
}
}
function readHomeComposerChipDraft(): HomeComposerChipDraft | null {
const raw = readHomeComposerDraft(HOME_COMPOSER_CHIP_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<HomeComposerChipDraft> | null;
if (!parsed || typeof parsed.pluginId !== 'string' || !parsed.pluginId) return null;
const parsedChipId = typeof parsed.chipId === 'string' ? parsed.chipId : null;
const legacyPrototypeSubtype = prototypeSubChipForActionChipId(parsedChipId);
const parsedPrototypeSubtype =
typeof parsed.prototypeSubtypeId === 'string'
? prototypeSubChipForSlug(parsed.prototypeSubtypeId)
: null;
return {
chipId: legacyPrototypeSubtype ? 'prototype' : parsedChipId,
pluginId: parsed.pluginId,
projectKind: typeof parsed.projectKind === 'string' ? (parsed.projectKind as ProjectKind) : null,
prototypeSubtypeId: parsedPrototypeSubtype?.slug ?? legacyPrototypeSubtype?.slug ?? null,
};
} catch {
return null;
}
}
function writeHomeComposerChipDraft(draft: HomeComposerChipDraft | null): void {
writeHomeComposerDraft(HOME_COMPOSER_CHIP_KEY, draft ? JSON.stringify(draft) : null);
}
// Drop the persisted draft once a run is actually created, so the just-sent
// prompt and pick don't resurrect the next time the Home tab mounts.
function clearHomeComposerDraft(): void {
writeHomeComposerDraft(HOME_COMPOSER_PROMPT_KEY, null);
writeHomeComposerDraft(HOME_COMPOSER_DESIGN_SYSTEM_KEY, null);
writeHomeComposerDraft(HOME_COMPOSER_DESIGN_SYSTEM_SCOPE_KEY, null);
writeHomeComposerChipDraft(null);
}
/**
* Seed the Home composer's prompt — used when another surface hands the user
* into Home with a starting prompt (e.g. Community's "使用提示词"/Prompt
* button). Writes the draft key (covers a true cold mount) AND dispatches a
* live event (covers the common case where `HomeView` is already mounted and
* just gets toggled visible — see the `HOME_COMPOSER_SEED_EVENT` note above).
*/
export function seedHomeComposerPrompt(prompt: string): void {
writeHomeComposerDraft(HOME_COMPOSER_PROMPT_KEY, prompt);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(HOME_COMPOSER_SEED_EVENT, { detail: { prompt } }));
}
}
export function HomeView({
isActive = true,
projects,
projectsLoading,
designSystems = EMPTY_DESIGN_SYSTEMS,
designSystemsLoading = false,
defaultDesignSystemId = null,
onSubmit,
onOpenProject,
onViewAllProjects,
onDeleteProject,
onDuplicateProject,
onRenameProject,
onBrowseRegistry,
onOpenIntegrations,
onOpenMcp,
onOpenNewProject,
onStartBlankProject,
promptHandoff,
isSharedProject,
onProjectShared,
onProjectShareFailed,
onProjectUnshared,
projectOwnerMemberIds,
skills = EMPTY_SKILLS,
skillsLoading = false,
connectors = EMPTY_CONNECTORS,
promptTemplates = EMPTY_PROMPT_TEMPLATES,
recommendation = null,
onRecommendationStart,
onRecommendationDismiss,
executionSwitcher,
artifactUpgradeSlot,
deepSeekV4FlashCampaignAudience = 'unknown',
onDeepSeekV4FlashCampaignUseNow,
deepSeekV4FlashCampaignMetricsConsent = false,
deepSeekV4FlashCampaignInstallationId = null,
}: Props) {
const { locale, t } = useI18n();
const analytics = useAnalytics();
const workspaceContextState = useWorkspaceContext();
const { context: workspaceContext } = workspaceContextState;
const pluginCatalogWorkspaceContext = workspaceResourceReadContext(workspaceContextState);
const lastSettledLocalCatalogScopeRef = useRef<LocalCatalogScope | null>(
localCatalogScopeFromWorkspaceContext(workspaceContext),
);
if (!workspaceContextState.identityChangePending) {
lastSettledLocalCatalogScopeRef.current =
localCatalogScopeFromWorkspaceContext(workspaceContext);
}
const pluginAccountGeneration = currentWorkspaceAccountGeneration();
const pluginCatalogOptions = {
workspaceContext: pluginCatalogWorkspaceContext,
accountGeneration: pluginAccountGeneration,
};
// Keep the provisional local catalogue available for default-template
// routing while Workspace discovery runs, but never expose that provisional
// projection in HomeHero. The prop below keeps the Examples rail in its
// stable loading shell until the Workspace identity and its exact cache
// partition have both settled.
const desiredPluginCatalogKey = workspaceContextState.identityChangePending
? null
: pluginCatalogCacheKey(pluginCatalogOptions);
// Team-wide catalog from the resource hub via the daemon; empty off-team / when
// the hub is unconfigured. Only the creator attribution is derived here — the
// shared/not-shared answer arrives as `isSharedProject` from EntryShell, which
// owns the optimistic layer the 全部项目 / 草稿 grids read from too.
const homeTeamProjects = useTeamProjects();
// projectId → sharing member id, so the strip can resolve "{creator}创建" for a
// teammate's shared project (a project absent here is the member's own local
// project → "我创建").
const homeProjectOwnerMemberIds = useMemo(
() => projectOwnerMemberIds ?? new Map(
homeTeamProjects.projects.map((teamProject) => [
teamProject.projectId,
teamProject.ownerMemberId,
]),
),
[homeTeamProjects.projects, projectOwnerMemberIds],
);
// P0 page_view page_name=home — fire once on mount. ref-keyed to survive
// re-renders that flip parent state without remounting HomeView.
const homePageViewFiredRef = useRef(false);
useEffect(() => {
if (homePageViewFiredRef.current) return;
homePageViewFiredRef.current = true;
trackPageView(analytics.track, { page_name: 'home' });
}, [analytics.track]);
// A project route fully unmounts HomeView. Restore the last successful
// catalog synchronously when Home mounts again so known creation actions do
// not become disabled merely because the 10-second refresh TTL elapsed while
// the user was in a project. The effect below still revalidates an expired
// catalog; only a true cold start (no successful catalog yet) stays guarded.
const initialPluginsRef = useRef<InstalledPluginRecord[] | null>(
desiredPluginCatalogKey ? readCachedVisiblePlugins(pluginCatalogOptions) : null,
);
const [pluginCatalogKey, setPluginCatalogKey] = useState<string | null>(
desiredPluginCatalogKey,
);
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>(
() => initialPluginsRef.current ?? [],
);
const [pluginsLoading, setPluginsLoading] = useState(
() => initialPluginsRef.current === null,
);
// Home stays mounted while entry-shell views and Workspaces change. Never
// commit one render with the previous identity's plugin catalogue: switch to
// the exact new cache partition synchronously, or mask the old rows while a
// deliberate identity change is unresolved.
if (pluginCatalogKey !== desiredPluginCatalogKey) {
const cached = desiredPluginCatalogKey
? readCachedVisiblePlugins(pluginCatalogOptions)
: null;
setPluginCatalogKey(desiredPluginCatalogKey);
setPlugins(cached ?? []);
setPluginsLoading(cached === null);
}
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
const [pendingChipId, setPendingChipId] = useState<string | null>(null);
const [pendingAuthoringChipId, setPendingAuthoringChipId] = useState<string | null>(null);
const [pendingAuthoringPrompt, setPendingAuthoringPrompt] = useState(PLUGIN_AUTHORING_PROMPT);
const [pendingAuthoringInputs, setPendingAuthoringInputs] = useState<Record<string, unknown>>(
() => buildPluginAuthoringInputs(undefined),
);
const [pendingPluginUseHandoff, setPendingPluginUseHandoff] =
useState<PendingPluginUseHandoff | null>(null);
// The persisted chip/plugin identity, read exactly once on mount (lazy
// initializer — mirrors `restoredDraftRef` below for the prompt/design
// system draft). Resolved into a full `active` binding by the restore
// effect further down once the plugin catalog has loaded, then cleared.
const [pendingChipRestore, setPendingChipRestore] = useState<HomeComposerChipDraft | null>(
() => readHomeComposerChipDraft(),
);
const [fallbackProjectKind, setFallbackProjectKind] = useState<ProjectKind | null>(null);
const [fallbackProjectMetadata, setFallbackProjectMetadata] =
useState<ProjectMetadata | null>(null);
const [active, setActive] = useState<ActivePlugin | null>(null);
const reconciledPluginCatalogKeyRef = useRef<string | null>(null);
const previousWorkspaceNameRef = useRef<string | null>(null);
// A placeholder-carousel scenario the user submitted on an empty composer.
// We seed the prompt + bind the template synchronously, then let an effect
// fire submit() once both have committed (submit() reads state, not args).
const [pendingCarouselSubmit, setPendingCarouselSubmit] = useState<{
text: string;
chipId: string | null;
} | null>(null);
const [sessionMode, setSessionMode] = useState<ChatSessionMode>('design');
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(null);
const [activeSkillCatalogScope, setActiveSkillCatalogScope] =
useState<LocalCatalogScope | null>(null);
const [selectedPluginContexts, setSelectedPluginContexts] = useState<SelectedPluginContext[]>([]);
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
const [selectedConnectorContexts, setSelectedConnectorContexts] = useState<SelectedConnectorContext[]>([]);
const [contextWorkspaceItems, setContextWorkspaceItems] = useState<WorkspaceContextItem[]>([]);
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
const [workingDir, setWorkingDir] = useState<string | null>(null);
// Token paired with `workingDir` when picked through the desktop host's
// native dialog. Spent on the post-creation working-dir POST so the
// daemon's desktop-auth gate accepts the path. Null for web picks.
const [workingDirToken, setWorkingDirToken] = useState<string | null>(null);
// Global design-system selection for the home composer. Persistent and
// independent of the active plugin / type chip so EVERY product kind (not
// just prototype/deck) can pick a design system; the choice is forwarded as
// the new project's `designSystemId`. Seeded from the user's published
// Personal default and re-seeded if that resolves async, until the user picks
// one explicitly (tracked by `designSystemTouchedRef` so a later default
// change never clobbers an explicit selection).
// Read the persisted composer draft exactly once per mount (see the module
// note above). Restoring here is what makes the prompt + design-system pick
// survive a tab switch, since the whole view is torn down on every switch.
const restoredDraftRef = useRef<{
prompt: string;
designSystemId: string | null;
designSystemCatalogScope: LocalCatalogScope | null;
} | null>(null);
if (restoredDraftRef.current === null) {
restoredDraftRef.current = {
prompt: readHomeComposerDraft(HOME_COMPOSER_PROMPT_KEY) ?? '',
designSystemId: readHomeComposerDraft(HOME_COMPOSER_DESIGN_SYSTEM_KEY),
designSystemCatalogScope: readLocalCatalogScopeDraft(
HOME_COMPOSER_DESIGN_SYSTEM_SCOPE_KEY,
),
};
}
const restoredDraft = restoredDraftRef.current;
const [designSystemId, setDesignSystemId] = useState<string | null>(() =>
restoredDraft.designSystemId ??
homeDefaultDesignSystemId(designSystems, defaultDesignSystemId),
);
const [designSystemCatalogScope, setDesignSystemCatalogScope] =
useState<LocalCatalogScope | null>(() =>
restoredDraft.designSystemId
? restoredDraft.designSystemCatalogScope
: localCatalogScopeFromWorkspaceContext(workspaceContext),
);
// A restored pick counts as user-touched so the async default re-seed effect
// below does not overwrite it once the catalogue resolves.
const designSystemTouchedRef = useRef(restoredDraft.designSystemId != null);
// Global most-recently-used working directories, surfaced in the picker's
// "Recent folders" submenu. Loaded from the daemon's app-config and bumped
// whenever the user picks a folder.
const [recentDirs, setRecentDirs] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
void fetchRecentLinkedDirs().then((dirs) => {
if (!cancelled) setRecentDirs(dirs);
});
return () => {
cancelled = true;
};
}, []);
const rememberRecentDir = useCallback(async (dir: string) => {
// Optimistically promote the dir to the front so the submenu updates
// immediately; the daemon also trims/de-dupes/caps the persisted list.
setRecentDirs((prev) => [dir, ...prev.filter((d) => d !== dir)].slice(0, 5));
const persisted = await pushRecentLinkedDir(dir);
setRecentDirs(persisted);
}, []);
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
const [mcpLoading, setMcpLoading] = useState(true);
const [prompt, setPrompt] = useState(() => restoredDraft.prompt);
// Treat a restored non-empty prompt as user-edited so the plugin/skill
// replacement guard still asks before clobbering it.
const [promptEditedByUser, setPromptEditedByUser] = useState(
() => restoredDraft.prompt.trim().length > 0,
);
// Persist the composer draft on every change so it survives the unmount that
// a tab switch triggers (see the module note above). Empty values clear the
// key rather than storing "".
useEffect(() => {
writeHomeComposerDraft(HOME_COMPOSER_PROMPT_KEY, prompt);
}, [prompt]);
useEffect(() => {
writeHomeComposerDraft(HOME_COMPOSER_DESIGN_SYSTEM_KEY, designSystemId);
}, [designSystemId]);
useEffect(() => {
writeHomeComposerDraft(
HOME_COMPOSER_DESIGN_SYSTEM_SCOPE_KEY,
designSystemId && designSystemCatalogScope
? JSON.stringify(designSystemCatalogScope)
: null,
);
}, [designSystemCatalogScope, designSystemId]);
// Persist the active chip/plugin identity the same way — only the
// serializable fields, not `active` itself (see the module note above).
// Clearing on `active === null` covers the explicit-clear (×) and the
// Ask-mode / skill-pick paths that reset `active` to null directly.
useEffect(() => {
writeHomeComposerChipDraft(
active
? {
chipId: active.chipId,
pluginId: active.record.id,
projectKind: active.projectKind,
...(active.prototypeSubtypeId
? { prototypeSubtypeId: active.prototypeSubtypeId }
: {}),
}
: null,
);
}, [active]);
// Live counterpart to the draft-key restore above (see the
// `HOME_COMPOSER_SEED_EVENT` module note) — picks up a `seedHomeComposerPrompt`
// call that fires while this HomeView instance is already mounted, which is
// the common case now that EntryShell keeps Home mounted across view
// switches instead of tearing it down.
useEffect(() => {
function onSeed(event: Event) {
const prompt = (event as CustomEvent<{ prompt: string }>).detail?.prompt;
if (typeof prompt !== 'string') return;
setPrompt(prompt);
setPromptEditedByUser(prompt.trim().length > 0);
}
window.addEventListener(HOME_COMPOSER_SEED_EVENT, onSeed);
return () => window.removeEventListener(HOME_COMPOSER_SEED_EVENT, onSeed);
}, []);
const [figmaModalOpen, setFigmaModalOpen] = useState(false);
const examplePromptInfoRef = useRef<ExamplePromptInfo | null>(null);
const handleExamplePromptStatusChange = useCallback((info: ExamplePromptInfo | null) => {
examplePromptInfoRef.current = info;
}, []);
const [error, setError] = useState<string | null>(null);
const [daemonRecoveryActive, setDaemonRecoveryActive] = useState(false);
useEffect(() => {
if (!daemonRecoveryActive) return;
let cancelled = false;
let timeout: number | null = null;
const probe = async () => {
const alive = await daemonIsLive();
if (cancelled) return;
if (alive) {
setDaemonRecoveryActive(false);
setError((current) => current === t('home.daemonRecovering') ? null : current);
return;
}
timeout = window.setTimeout(() => void probe(), 1_500);
};
void probe();
return () => {
cancelled = true;
if (timeout !== null) window.clearTimeout(timeout);
};
}, [daemonRecoveryActive, t]);
// Composer in-flight guard: disables the send button, shows Sending…, and
// swallows repeat clicks across the whole async create tail.
const [sending, setSending] = useState(false);
const [elevenLabsVoices, setElevenLabsVoices] = useState<AudioVoiceOption[]>([]);
const [elevenLabsVoicesLoading, setElevenLabsVoicesLoading] = useState(false);
// Live AIHubMix image catalogue merged into the home media composer's model
// picker (replaces the static aihubmix seeds when the fetch resolves).
const aihubmixImageModels = useAIHubMixImageModels();
const composerImageModels = useMemo(
() => mergeAihubmixImageModels(IMAGE_MODELS, aihubmixImageModels),
[aihubmixImageModels],
);
const [elevenLabsVoicesLoaded, setElevenLabsVoicesLoaded] = useState(false);
const [elevenLabsVoicesError, setElevenLabsVoicesError] = useState<string | null>(null);
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
// 飞书 recvqxDuYM6Uxk: the creation page's template detail entries (the
// active plugin chip, the @-mention hover card's Details) open the
// LIGHTWEIGHT community template preview — header title/category + close,
// footer category + Remix — for records that project into the template
// catalogue. Plugins outside that projection (design systems, utilities)
// keep the full PluginDetailsModal. The Community gallery card owns the
// full modal now, so the two surfaces are swapped, not duplicated.
const detailsTemplate = useMemo(() => {
if (!detailsRecord) return null;
return (
buildCommunityTemplates(plugins, locale, t, workspaceContext)
.find((template) => template.id === detailsRecord.id) ?? null
);
}, [detailsRecord, plugins, locale, t, workspaceContext]);
// Same synchronous single-flight gate the Community remix path uses: the
// lightweight preview's Remix kicks off one project create; clicks landing
// before React re-renders must all see the lock immediately, so a plain
// state flag is not enough (see CommunityView's remixingIdRef note).
const templateRemixInFlightRef = useRef(false);
const [templateRemixBusy, setTemplateRemixBusy] = useState(false);
const [detailsSkill, setDetailsSkill] = useState<SkillSummary | null>(null);
const [pendingReplacement, setPendingReplacement] = useState<PendingReplacement | null>(null);
// Surface_view fires when the replacement modal becomes visible. Tied
// to the {before, after} pair so reopening with the same pair after a
// close doesn't double-fire, but a fresh pair always does.
const lastPluginReplacementViewRef = useRef<string | null>(null);
useEffect(() => {
if (!pendingReplacement) {
lastPluginReplacementViewRef.current = null;
return;
}
const key = `${pendingReplacement.pluginBefore ?? ''}->${pendingReplacement.pluginAfter}`;
if (lastPluginReplacementViewRef.current === key) return;
lastPluginReplacementViewRef.current = key;
trackPluginReplacementModalSurfaceView(analytics.track, {
page_name: 'home',
area: 'plugin_replacement_modal',
});
}, [pendingReplacement, analytics.track]);
// Community gallery analytics. Opening a tile fires both a ui_click on
// the card (the funnel's denominator) and a surface_view on the detail
// modal it reveals (the numerator); the ↗ that jumps straight to the
// real example page is its own ui_click so "go to the finished thing"
// stays distinct from "open the detail modal". plugin_id / plugin_type
// mirror PluginsView so the two surfaces join on the same keys.
const handleCommunityOpenDetails = useCallback(
(record: InstalledPluginRecord) => {
const pluginId = record.sourceMarketplaceEntryName ?? record.id;
const pluginType = record.marketplaceTrust ?? 'official';
trackCommunityGalleryClick(analytics.track, {
page_name: 'home',
area: 'community_gallery',
element: 'card',
plugin_id: pluginId,
plugin_type: pluginType,
});
trackPluginDetailModalSurfaceView(analytics.track, {
page_name: 'home',
area: 'plugin_detail_modal',
plugin_id: pluginId,
plugin_type: pluginType,
});
setDetailsRecord(record);
},
[analytics.track],
);
const inputRef = useRef<HomeHeroHandle | null>(null);
const homeViewRef = useRef<HTMLDivElement | null>(null);
const consumedHandoffIdRef = useRef<number | null>(null);
const pendingPromptFocusEndRef = useRef(false);
const activePluginApplyRequestRef = useRef(0);
const pluginCatalogRequestGenerationRef = useRef(0);
const pluginCatalogReloadRef = useRef<(
force?: boolean,
supersede?: boolean,
) => Promise<void>>(
async () => {},
);
const pluginCatalogReloadInFlightRef = useRef<{
key: string;
promise: Promise<void>;
} | null>(null);
const pluginCatalogStaleRef = useRef(false);
const homeActiveRef = useRef(isActive);
homeActiveRef.current = isActive;
const desiredPluginCatalogKeyRef = useRef(desiredPluginCatalogKey);
desiredPluginCatalogKeyRef.current = desiredPluginCatalogKey;
const scrollHomeToTop = useCallback(() => {
requestAnimationFrame(() => {
const scrollContainer = homeViewRef.current?.closest('.entry-main--scroll');
if (!(scrollContainer instanceof HTMLElement)) return;
smoothScrollToTop(scrollContainer);
});
}, []);
useEffect(() => {
if (!desiredPluginCatalogKey) return;
let cancelled = false;
let ownedPromise: Promise<void> | null = null;
const issuedCatalogKey = desiredPluginCatalogKey;
// On mount use the cache-aware loader (skips the network when warm); an
// explicit plugins-changed event forces a fresh fetch.
const load = (force = false, supersede = false): Promise<void> => {
const current = pluginCatalogReloadInFlightRef.current;
if (!supersede && current?.key === issuedCatalogKey) return current.promise;
const requestGeneration = ++pluginCatalogRequestGenerationRef.current;
const promise = (force
? listPlugins(pluginCatalogOptions)
: listPluginsFresh(pluginCatalogOptions)).then((rows) => {
if (
cancelled
|| requestGeneration !== pluginCatalogRequestGenerationRef.current
|| desiredPluginCatalogKeyRef.current !== issuedCatalogKey
) return;
setPluginCatalogKey(issuedCatalogKey);
setPlugins(rows);
setPluginsLoading(false);
}).finally(() => {
if (pluginCatalogReloadInFlightRef.current?.promise === promise) {
pluginCatalogReloadInFlightRef.current = null;
}
});
pluginCatalogReloadInFlightRef.current = { key: issuedCatalogKey, promise };
ownedPromise = promise;
return promise;
};
pluginCatalogReloadRef.current = load;
if (homeActiveRef.current && pluginCatalogWorkspaceContext?.workspaceType !== 'team') load();
else pluginCatalogStaleRef.current = true;
const onChanged = () => {
// A mutation event is newer than any pending snapshot and must supersede
// it; only lifecycle catch-up joins the initial read.
if (homeActiveRef.current) load(true, true);
else pluginCatalogStaleRef.current = true;
};
window.addEventListener('open-design:plugins-changed', onChanged);
return () => {
cancelled = true;
// A Workspace-directory refresh can briefly mask the catalog identity
// (K -> null -> K). Do not let the remounted K effect join this effect's
// cancelled promise: its response is intentionally prevented from
// committing, so reusing it would leave the new surface in the cold
// `pluginsLoading` state forever even though `/api/plugins` succeeded.
// A newer request has a different promise and must remain registered.
const inFlight = pluginCatalogReloadInFlightRef.current;
if (ownedPromise && inFlight?.promise === ownedPromise) {
pluginCatalogReloadInFlightRef.current = null;
}
if (pluginCatalogReloadRef.current === load) {
pluginCatalogReloadRef.current = async () => {};
}
window.removeEventListener('open-design:plugins-changed', onChanged);
};
}, [desiredPluginCatalogKey, pluginCatalogWorkspaceContext?.workspaceType]);
useEffect(() => {
if (!isActive || !desiredPluginCatalogKey || !pluginCatalogStaleRef.current) return;
if (pluginCatalogWorkspaceContext?.workspaceType === 'team') return;
pluginCatalogStaleRef.current = false;
pluginCatalogReloadRef.current(true);
}, [desiredPluginCatalogKey, isActive, pluginCatalogWorkspaceContext?.workspaceType]);
const handlePluginStreamActive = useWorkspaceSnapshotActivation({
enabled: isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team',
identity: desiredPluginCatalogKey ?? 'no-plugin-catalog',
refresh: () => { void pluginCatalogReloadRef.current(true, true); },
});
useWorkspaceInvalidation({}, {
workspaceContext:
isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team'
? pluginCatalogWorkspaceContext
: null,
enabled: isActive && pluginCatalogWorkspaceContext?.workspaceType === 'team',
// App owns the global Skill/Design System catch-up. Home only refreshes
// its plugin projection.
onActive: () => {
pluginCatalogStaleRef.current = false;
handlePluginStreamActive();
},
});
useEffect(() => {
let cancelled = false;
void fetchMcpServers().then((result) => {
if (cancelled) return;
setMcpServers(result?.servers ?? []);
setMcpLoading(false);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (active?.mediaSurface !== 'audio' || active.inputs.model !== 'elevenlabs-v3') return;
if (elevenLabsVoicesLoaded) return;
const controller = new AbortController();
setElevenLabsVoicesLoading(true);
setElevenLabsVoicesError(null);
void fetchElevenLabsVoiceOptions(controller.signal)
.then((voices) => {
if (controller.signal.aborted) return;
setElevenLabsVoices(voices);
setElevenLabsVoicesLoaded(true);
})
.catch((err) => {
if (controller.signal.aborted) return;
setElevenLabsVoices([]);
setElevenLabsVoicesLoaded(true);
setElevenLabsVoicesError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (controller.signal.aborted) return;
setElevenLabsVoicesLoading(false);
});
return () => controller.abort();
}, [active?.mediaSurface, active?.inputs.model, elevenLabsVoicesLoaded]);
const elevenLabsVoiceWarning = useMemo(() => {
if (active?.mediaSurface !== 'audio' || active.inputs.model !== 'elevenlabs-v3') return null;
if (elevenLabsVoicesError) return elevenLabsVoicesError;