Add FileChooser InputType to the Interaction Service#14882
Add FileChooser InputType to the Interaction Service#14882mcumming wants to merge 6 commits intodotnet:mainfrom
Conversation
This commit introduces a new FileChooser input type to the Aspire
Interaction Service, enabling users to select files via the native
OS/browser file picker dialog within interaction prompts.
## Changes across the full stack
### Aspire.Hosting (Server-side model)
- Added 'FileChooser' member to the 'InputType' enum in
IInteractionService.cs, providing a new input type for file selection.
- Added validation handling for 'FileChooser' in InteractionService.cs
RunValidationAsync method. File chooser values are validated for
required/empty checks but have no additional format constraints.
- Updated the public API surface in Aspire.Hosting.cs to include the
new enum member (FileChooser = 5).
### Proto/gRPC contract (dashboard_service.proto)
- Added 'INPUT_TYPE_FILE_CHOOSER = 6' to the InputType enum in the
protobuf definition, extending the gRPC contract between the AppHost
and Dashboard.
### Dashboard Service (Mapping layer)
- Added bidirectional mapping for FileChooser in both MapInputType
methods in DashboardService.cs, converting between the Hosting and
Proto InputType enums.
### Dashboard UI (Blazor frontend)
- Added a new 'FileChooser' case in InteractionsInputDialog.razor that
renders a read-only FluentTextField (showing the selected filename)
alongside a FluentInputFile component with a Browse button. The file
is read into memory via InputFileMode.Stream and its text content is
passed as the input Value.
- Added OnFileSelected handler in InteractionsInputDialog.razor.cs that
reads the uploaded file stream into a string and sets both the Value
(file content) and FileDisplayName (original filename).
- Added FileDisplayName property to InputViewModel.cs for displaying the
original filename while Value holds the file content.
- Added CSS styles for the file-chooser-container layout in
InteractionsInputDialog.razor.css.
- Added localization strings for the file chooser placeholder and browse
button text in Dialogs.resx and Dialogs.Designer.cs.
### CLI (Aspire.Cli)
- Added 'FileChooser' to the CLI's own InputType enum in
ExtensionBackchannelDataTypes.cs.
- Added FileChooser case in PipelineCommandBase.cs that prompts for a
file path string in CLI mode.
### Playground sample (new project)
- Created a new 'Interactions' playground project dedicated to testing
interaction service enhancements, with four sample commands:
- File chooser: single required file picker
- File chooser with other inputs: file picker combined with text and
boolean inputs
- File chooser (optional): optional file picker
- File chooser (display content): reads and displays the selected
file's text content in a message box
- Added the project to the solution file (Aspire.slnx).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14882Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14882" |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a new FileChooser input type to the Aspire Interaction Service, enabling file selection in Dashboard prompts and a CLI fallback path prompt.
Changes:
- Added
FileChoosertoInputTypeacross Hosting API, proto contract, Dashboard mapping, and CLI backchannel types. - Implemented Dashboard UI for file picking (Browse button + filename display) and a Blazor handler to read the selected file.
- Added an Interactions playground AppHost project with sample commands exercising the new input type.
Reviewed changes
Copilot reviewed 33 out of 35 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| Aspire.slnx | Adds the new Interactions playground project to the solution. |
| src/Aspire.Hosting/api/Aspire.Hosting.cs | Updates public API enum with FileChooser. |
| src/Aspire.Hosting/IInteractionService.cs | Adds FileChooser to the hosting model enum and docs. |
| src/Aspire.Hosting/InteractionService.cs | Adds a validation switch-case for the new input type. |
| src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto | Extends gRPC enum with INPUT_TYPE_FILE_CHOOSER. |
| src/Aspire.Hosting/Dashboard/DashboardService.cs | Adds bidirectional mapping for FileChooser. |
| src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor | Renders FileChooser UI (read-only filename + browse button). |
| src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs | Implements file selection handler that sets input values. |
| src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css | Adds layout styles for the FileChooser UI. |
| src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs | Adds FileDisplayName for filename display. |
| src/Aspire.Dashboard/Resources/Dialogs.resx | Adds localized strings for placeholder and browse label. |
| src/Aspire.Dashboard/Resources/xlf/Dialogs.*.xlf | Adds new localization entries for multiple languages. |
| src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs | Adds FileChooser to CLI backchannel enum. |
| src/Aspire.Cli/Commands/PipelineCommandBase.cs | Adds CLI fallback handling for FileChooser prompts. |
| playground/Interactions/Interactions.AppHost/* | Adds a new playground AppHost and sample interaction commands. |
Files not reviewed (1)
- src/Aspire.Dashboard/Resources/Dialogs.Designer.cs: Language not supported
Comments suppressed due to low confidence (1)
src/Aspire.Hosting/InteractionService.cs:1
- This comment states FileChooser values are file paths, but the Dashboard UI handler sets
Valueto the file content. Either update the comment (and any assumptions in validation) to match the “content in Value” contract, or change the UI/model soValueis a path and content is provided separately.
| private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel) | ||
| { | ||
| var file = args.FirstOrDefault(); | ||
| if (file?.Stream != null) | ||
| { | ||
| using var reader = new StreamReader(file.Stream); | ||
| inputModel.Value = await reader.ReadToEndAsync(); | ||
| inputModel.FileDisplayName = file.Name; | ||
| _editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel)); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Reading the entire uploaded file into a string with no explicit maximum size can enable memory exhaustion (and will also corrupt binary files / non-text encodings). Consider enforcing a maximum upload size, and represent the payload in a binary-safe form (e.g., bytes/base64) or explicitly constrain FileChooser to text files and document/validate that constraint.
| private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel) | |
| { | |
| var file = args.FirstOrDefault(); | |
| if (file?.Stream != null) | |
| { | |
| using var reader = new StreamReader(file.Stream); | |
| inputModel.Value = await reader.ReadToEndAsync(); | |
| inputModel.FileDisplayName = file.Name; | |
| _editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel)); | |
| } | |
| } | |
| // Maximum number of bytes to read from an uploaded file into memory. | |
| // Files larger than this will be truncated to this limit when read. | |
| private const long MaxUploadedFileBytes = 1024 * 1024; // 1 MB | |
| private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel) | |
| { | |
| var file = args.FirstOrDefault(); | |
| if (file?.Stream != null) | |
| { | |
| // Wrap the file stream to prevent reading more than MaxUploadedFileBytes into memory. | |
| using var limitedStream = new LimitedStream(file.Stream, MaxUploadedFileBytes); | |
| using var reader = new StreamReader(limitedStream); | |
| inputModel.Value = await reader.ReadToEndAsync(); | |
| inputModel.FileDisplayName = file.Name; | |
| _editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel)); | |
| } | |
| } | |
| private sealed class LimitedStream : Stream | |
| { | |
| private readonly Stream _innerStream; | |
| private readonly long _maxBytes; | |
| private long _bytesRead; | |
| public LimitedStream(Stream innerStream, long maxBytes) | |
| { | |
| _innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream)); | |
| _maxBytes = maxBytes >= 0 ? maxBytes : throw new ArgumentOutOfRangeException(nameof(maxBytes)); | |
| } | |
| public override bool CanRead => _innerStream.CanRead; | |
| public override bool CanSeek => _innerStream.CanSeek; | |
| public override bool CanWrite => false; | |
| public override long Length => _innerStream.Length; | |
| public override long Position | |
| { | |
| get => _innerStream.Position; | |
| set => _innerStream.Position = value; | |
| } | |
| public override void Flush() => _innerStream.Flush(); | |
| public override int Read(byte[] buffer, int offset, int count) | |
| { | |
| if (_bytesRead >= _maxBytes) | |
| { | |
| return 0; | |
| } | |
| var remaining = _maxBytes - _bytesRead; | |
| if (count > remaining) | |
| { | |
| count = (int)remaining; | |
| } | |
| var read = _innerStream.Read(buffer, offset, count); | |
| _bytesRead += read; | |
| return read; | |
| } | |
| public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | |
| { | |
| if (_bytesRead >= _maxBytes) | |
| { | |
| return 0; | |
| } | |
| var remaining = _maxBytes - _bytesRead; | |
| if (count > remaining) | |
| { | |
| count = (int)remaining; | |
| } | |
| var read = await _innerStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false); | |
| _bytesRead += read; | |
| return read; | |
| } | |
| public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); | |
| public override void SetLength(long value) => _innerStream.SetLength(value); | |
| public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); | |
| } |
There was a problem hiding this comment.
Fixed in 5c2f9f6. Added a LimitedStream wrapper that caps reads at 1MB (MaxUploadedFileBytes). The stream silently truncates beyond the limit. Also added both the array-based and Memory<byte>-based ReadAsync overrides to satisfy CA1844.
| _editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel)); | ||
| } |
There was a problem hiding this comment.
The UI binds the visible field to inputModel.FileDisplayName, but the edit context notification here appears to target a single field identifier (likely Value). This can prevent required/validation state from updating for FileDisplayName when a file is selected programmatically. Notify the correct field identifier(s) for FileDisplayName (and Value if needed), and consider clearing both properties + notifying when no file is selected to avoid stale values.
| _editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel)); | |
| } | |
| _editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.Value))); | |
| _editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.FileDisplayName))); | |
| } | |
| else | |
| { | |
| inputModel.Value = string.Empty; | |
| inputModel.FileDisplayName = string.Empty; | |
| _editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.Value))); | |
| _editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.FileDisplayName))); | |
| } |
There was a problem hiding this comment.
Fixed in 5c2f9f6. Now notifying both Value and FileDisplayName field identifiers via explicit FieldIdentifier construction. Also added an else branch that clears both properties and notifies when no file is selected, preventing stale values.
| // Used to track secret text visibility state | ||
| public bool IsSecretTextVisible { get; set; } | ||
|
|
||
| // Used to display the original filename for FileChooser inputs while Value holds the temp file path. |
There was a problem hiding this comment.
This comment conflicts with the current Dashboard implementation, which assigns file contents to Value (not a temp file path). Update the comment to reflect the actual behavior, or adjust the implementation to match the documented “temp file path” model.
| // Used to display the original filename for FileChooser inputs while Value holds the temp file path. | |
| // Used to display the original filename for FileChooser inputs while Value holds the file contents. |
There was a problem hiding this comment.
Fixed in 5c2f9f6. Updated the comment to say 'file contents' instead of 'temp file path', matching the actual stream-based implementation.
| logger.LogInformation("Selected file: {Value}", result.Data.Value); | ||
|
|
||
| _ = interactionService.PromptMessageBoxAsync( | ||
| "File selected", | ||
| $"You selected: {result.Data.Value}", | ||
| new MessageBoxInteractionOptions { Intent = MessageIntent.Success }); |
There was a problem hiding this comment.
These samples log and display result.Data.Value, which (per the current Dashboard implementation) contains the full file contents. This can leak secrets (certs/configs) into logs/UI and can also produce very large log entries. Prefer logging/displaying only the filename (or size/preview with strict truncation), and avoid writing raw content unless the sample is explicitly about showing content (and then keep a tight size cap).
There was a problem hiding this comment.
Fixed in 5c2f9f6. All sample commands now log only the file size (character count) instead of raw content. The file-chooser and multi-input samples log ({Size} characters); the multi-input sample also distinguishes FileChooser inputs from other types when logging. The display-content sample still shows a truncated preview in a message box (for demonstration purposes) but no longer writes raw content to logs.
…tions, and sanitize logging - Added a 1MB LimitedStream wrapper around file uploads to prevent memory exhaustion from large files. The stream caps reads at MaxUploadedFileBytes (1MB) and silently truncates beyond that. Added both array-based and memory-based ReadAsync overrides. - Fixed field change notifications in OnFileSelected to notify both the Value and FileDisplayName field identifiers, ensuring validation state updates correctly in the edit context. Also handles the empty selection case by clearing both properties. - Fixed stale comment on FileDisplayName property to correctly state that Value holds file contents (not a temp file path). - Updated all playground sample commands to avoid logging raw file contents, which could leak secrets (certs, configs) into logs. Commands now log only the file size in characters. The display-content sample still shows a truncated preview in a message box but no longer writes the raw content to logs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Added a MaxFileSize property (long?) to InteractionInput that allows callers to override the default 1MB upload size limit per input. The property follows the same validation pattern as MaxLength, rejecting values less than or equal to zero. - Added MaxFileSize property to InteractionInput in IInteractionService.cs - Added max_file_size field (int64, field 16) to the proto definition - Added mapping in DashboardService.cs to pass the value to the proto - Updated the Dashboard dialog to read MaxFileSize from the input and fall back to DefaultMaxUploadedFileBytes (1MB) when not specified - Updated the display-content playground sample to demonstrate setting MaxFileSize = 5MB - Updated public API surface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The image should not have been committed to the repo. It was intended only for the PR description. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add comprehensive unit tests for the new FileChooser input type across the test infrastructure: InteractionServiceTests (Aspire.Hosting.Tests): - PromptInputsAsync_FileChooserWithValue_PassesValidation: Verifies that a FileChooser input with a non-empty value passes validation when the interaction is completed, confirming no format-specific validation constraints exist for file content. - PromptInputsAsync_FileChooserRequiredEmpty_ReturnErrors: Verifies that a required FileChooser input with an empty value produces a 'Value is required.' validation error and prevents interaction completion. - PromptInputsAsync_FileChooserOptionalEmpty_PassesValidation: Verifies that an optional FileChooser input with an empty value passes validation successfully. - InteractionInput_MaxFileSize_RejectsInvalidValues: Verifies that setting MaxFileSize to 0 or negative values throws ArgumentOutOfRangeException, consistent with MaxLength validation. - InteractionInput_MaxFileSize_AcceptsValidValues: Verifies that valid positive MaxFileSize values are accepted and stored correctly. - InteractionInput_MaxFileSize_DefaultsToNull: Verifies that MaxFileSize defaults to null when not explicitly set. InputViewModelTests (Aspire.Dashboard.Components.Tests): - InputViewModel_FileChooser_DefaultsToEmptyValue: Verifies that a FileChooser InputViewModel initializes with empty Value and null FileDisplayName. - InputViewModel_FileChooser_FileDisplayNameIsIndependentOfValue: Verifies that FileDisplayName and Value can be set independently, as FileDisplayName holds the original filename while Value holds the file content. - InputViewModel_FileChooser_SetInputResetsFileDisplayName: Verifies the behavior of SetInput with FileChooser inputs and FileDisplayName retention. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@mcumming I implemented basically exactly what you have in your PR. We ended up not merging it because there are file size limitations when using the existing gRPC call. For example, a 50 megabyte file would exceed size limits and fail. My old PR: #10233 I think to remove the file size limit there would need to be a new API for uploading the file, it is written to a temporary disk location, the interactioninput result in the app host would need to know the location of the file (probably there would be a shared ID), it has a easy way to access the file, and the file is cleaned up when it's no longer used (this could be tricky, I don't remember if there is a "lifetime" on interaction input results to know where it's safe to do clean up) |
That's really funny, because that was what CoPilot implemented to begin with (before the first commit), then I directed it to upload the file. |
Plan: Adjust FileChooser to Stream-Based API with Temp File TransportProblem StatementJamesNK (comment) pointed out that the current FileChooser implementation (sending file content as a davidfowl's feedback (comment):
And (comment):
Component ArchitectureCurrent Data Flow (Before Changes)sequenceDiagram
participant Browser as Browser
participant Dashboard as Dashboard Server<br/>(Blazor Server)
participant AppHost as AppHost<br/>(InteractionService)
participant Consumer as Extension Code<br/>(Consumer)
Note over AppHost,Consumer: Extension registers a FileChooser interaction
Consumer->>AppHost: PromptInputAsync(FileChooser)
AppHost->>Dashboard: gRPC Stream: WatchInteractionsResponseUpdate<br/>(interaction prompt with FileChooser input)
Dashboard->>Browser: Render FluentInputFile + Browse button
Browser->>Dashboard: User selects file via native picker
Note over Dashboard: ⚠️ Reads ENTIRE file into memory<br/>stores content as string in Value
Dashboard->>AppHost: gRPC Stream: WatchInteractionsRequestUpdate<br/>value = file content string (⚠️ hits 4MB limit)
AppHost->>Consumer: InteractionResult.Data.Value = file content string
Note over Consumer: Consumer gets raw string content<br/>No stream access, no filename metadata
sequenceDiagram
participant CLI as CLI Terminal
participant Backchannel as AppHost RPC Target<br/>(StreamJsonRpc)
participant AppHost as AppHost<br/>(InteractionService)
participant Consumer as Extension Code<br/>(Consumer)
Consumer->>AppHost: PromptInputAsync(FileChooser)
AppHost->>Backchannel: Prompt activity via backchannel
Backchannel->>CLI: Display prompt for file path
CLI->>Backchannel: User types file path string
Backchannel->>AppHost: CompletePromptResponseAsync<br/>(PublishingPromptInputAnswer.Value = typed path)
AppHost->>Consumer: InteractionResult.Data.Value = typed path string
Proposed Data Flow (After Changes)sequenceDiagram
participant Browser as Browser
participant Dashboard as Dashboard Server<br/>(Blazor Server)
participant TempFS as Temp File System<br/>(aspire-uploads/)
participant AppHost as AppHost<br/>(InteractionService)
participant FSService as FileSystemService<br/>(Cleanup Tracking)
participant Consumer as Extension Code<br/>(Consumer)
Note over AppHost,Consumer: Extension registers a FileChooser interaction
Consumer->>AppHost: PromptInputAsync(FileChooser)
AppHost->>Dashboard: gRPC Stream: WatchInteractionsResponseUpdate<br/>(interaction prompt with FileChooser input)
Dashboard->>Browser: Render FluentInputFile + Browse button
Browser->>Dashboard: User selects file via native picker
Dashboard->>TempFS: Save file to temp directory<br/>(no size limit)
Note over TempFS: File saved as<br/>aspire-uploads/{guid}
Dashboard->>AppHost: gRPC Stream: WatchInteractionsRequestUpdate<br/>value = temp file path (small string ✅)<br/>file_name = "original.csv"<br/>file_size = 52428800
AppHost->>FSService: Register temp file for cleanup tracking
AppHost->>Consumer: InteractionResult with<br/>Value = file path, FileName = "original.csv"
Consumer->>Consumer: await input.OpenFileStreamAsync()
Note over Consumer: Extension method opens FileStream<br/>from temp path (ValueTask<Stream?>)
Consumer->>Consumer: Read stream, process file
Note over FSService: On AppHost shutdown:<br/>FileSystemService.Dispose()<br/>deletes tracked temp files
sequenceDiagram
participant CLI as CLI Terminal
participant Backchannel as AppHost RPC Target<br/>(StreamJsonRpc)
participant AppHost as AppHost<br/>(InteractionService)
participant Consumer as Extension Code<br/>(Consumer)
Consumer->>AppHost: PromptInputAsync(FileChooser)
AppHost->>Backchannel: Prompt activity via backchannel
Backchannel->>CLI: Display prompt for file path
CLI->>Backchannel: User types: /path/to/data.csv
Note over Backchannel: Set Value = /path/to/data.csv<br/>Set FileName = data.csv<br/>(no temp file needed)
Backchannel->>AppHost: CompletePromptResponseAsync<br/>(Value = path, FileName = data.csv)
AppHost->>Consumer: InteractionResult with<br/>Value = /path/to/data.csv, FileName = "data.csv"
Consumer->>Consumer: await input.OpenFileStreamAsync()
Note over Consumer: Extension method opens FileStream<br/>directly from user's file path
Component Interaction Mapgraph TB
subgraph BrowserLayer["Browser (User's Machine)"]
UI["FluentInputFile<br/>+ Browse Button"]
end
subgraph DashboardProcess["Dashboard Server Process"]
Razor["InteractionsInputDialog.razor.cs<br/>(saves file to temp dir)"]
VM["InputViewModel<br/>(Value=path, FileDisplayName, FileSize)"]
DS["DashboardService.cs<br/>(gRPC endpoint)"]
end
subgraph AppHostProcess["AppHost Process"]
DSD["DashboardServiceData<br/>(maps proto → InteractionInput)"]
IS["InteractionService<br/>(interaction state machine)"]
FSS["FileSystemService<br/>(temp file tracking + cleanup)"]
RPC["AppHostRpcTarget<br/>(StreamJsonRpc endpoint)"]
PAR["PipelineActivityReporter<br/>(CLI answer processing)"]
end
subgraph ConsumerCode["Extension / Consumer Code"]
EXT["Extension method:<br/>OpenFileStreamAsync()<br/>→ ValueTask<Stream?>"]
DEV["Developer's code<br/>(reads stream)"]
end
subgraph CLIProcess["CLI Process"]
CMD["PipelineCommandBase<br/>(prompts for file path)"]
BC["IAppHostCliBackchannel<br/>(StreamJsonRpc client)"]
end
subgraph TempStorage["Local File System"]
TMP["aspire-uploads/<br/>{guid} temp files"]
USR["User's actual file<br/>(CLI scenario)"]
end
UI -->|"SignalR"| Razor
Razor -->|"saves file"| TMP
Razor --> VM
VM -->|"Value=path"| DS
DS -->|"gRPC bidirectional stream<br/>WatchInteractions"| DSD
DSD -->|"maps file_name, value"| IS
IS -->|"registers temp file"| FSS
IS -->|"InteractionResult"| DEV
DEV -->|"OpenFileStreamAsync()"| EXT
EXT -->|"opens FileStream from"| TMP
CMD -->|"user types path"| BC
BC -->|"StreamJsonRpc"| RPC
RPC --> PAR
PAR -->|"ProcessInputs"| IS
EXT -.->|"CLI scenario:<br/>opens from"| USR
style TMP fill:#fff3cd,stroke:#ffc107
style USR fill:#fff3cd,stroke:#ffc107
style FSS fill:#d4edda,stroke:#28a745
style EXT fill:#cce5ff,stroke:#0066cc
Architectural Design DecisionsDecision 1: Stream-based consumer API vs. string content in
|
| Option | Description | Outcome |
|---|---|---|
A. File content in Value (original PR) |
Read file into memory, send as string via gRPC value field |
❌ Rejected — hits gRPC's ~4MB message size limit; buffers entire file in memory; only works for text files. This is what blocked JamesNK's PR #10233. |
B. byte[] property on InteractionInput |
Store file content as byte[] instead of string |
❌ Rejected by davidfowl: "The user should get a stream, not byte[].." Still buffers entire file in memory. |
C. OpenFileStream() instance method on InteractionInput |
Add a synchronous Stream-returning method directly on InteractionInput |
❌ Rejected — inconsistent with Aspire's API patterns. InteractionInput is a plain data container (all values are strings, no IDisposable, no resource methods). Adding a Stream-returning method would break this pattern. No other interaction input type returns a resource. |
D. New PromptFileAsync() on IInteractionService |
Separate method returning InteractionResult<InteractionFileResult> with a Stream |
❌ Rejected — creates a parallel API path for files vs. other inputs. Doesn't work for mixed-input dialogs (file + text + boolean in one prompt). |
✅ E. Async extension method OpenFileStreamAsync() |
Extension method on InteractionInput returning ValueTask<Stream?>. Value holds file path, FileName holds original name. |
✅ Chosen — keeps InteractionInput a clean data container. Follows Aspire's ValueTask<T> async pattern (GetConnectionStringAsync, GetValueAsync). "Fakes it" per davidfowl: today opens a local FileStream, tomorrow can be swapped to actual gRPC streaming without changing consumer code. |
Decision 2: Transport mechanism (Dashboard → AppHost)
Chosen: Temp file on disk + file path over gRPC
| Option | Description | Outcome |
|---|---|---|
| A. Content in gRPC message (original PR) | Send file bytes in the existing value field |
❌ Rejected — gRPC has a ~4MB default max message size. Would need MaxReceiveMessageSize increase, and still buffers everything in memory on both sides. |
| B. gRPC client streaming | New streaming RPC to upload file in chunks | ❌ Rejected for now — JamesNK outlined the complexity: new streaming API, temp disk storage, shared file IDs, association with interaction inputs, cleanup. Significant protocol change. davidfowl suggested this can come later: "can change the underlying protocol in 9.5 to actually support it." |
| ✅ C. Temp file + file path | Dashboard saves file to temp directory, sends only the path + metadata over gRPC | ✅ Chosen — per davidfowl: "Local apphost, the files are all on the same computer so there's no need to send it over grpc. The file path is enough." No size limits. Small gRPC messages. Dashboard (Blazor Server) and AppHost are on the same machine in local dev. |
Decision 3: Temp file cleanup strategy
Chosen: FileSystemService tracking + startup sweep
| Option | Description | Outcome |
|---|---|---|
A. FileOptions.DeleteOnClose |
OS auto-deletes file when last handle closes | ❌ Rejected — platform-specific behavior. On Windows, works via FILE_FLAG_DELETE_ON_CLOSE. On Linux, requires separate File.Delete after open (unlinks inode). Fragile and prevents re-opening the stream. |
| B. Timer-based grace period | Delete temp files after N minutes | ❌ Rejected — arbitrary timing; complex to implement; risks deleting before consumer reads or keeping files too long. |
C. IDisposable on InteractionInput |
Make InteractionInput disposable, delete temp file on dispose |
❌ Rejected — no other interaction input type is disposable. Would be a breaking pattern change for a data container. |
✅ D. FileSystemService tracking + startup sweep |
Register temp files with Aspire's existing FileSystemService infrastructure. Deleted on AppHost shutdown. Stale files from crashed sessions cleaned on next startup. |
✅ Chosen — reuses proven infrastructure. FileSystemService already handles temp file tracking, graceful error handling, and ASPIRE_PRESERVE_TEMP_FILES debug flag. No custom lifecycle management needed. |
Decision 4: Where FileName lives
Chosen: String property on InteractionInput
| Option | Description | Outcome |
|---|---|---|
A. Pack into Value alongside path |
Encode as `path | filename` in Value |
B. Separate InteractionFileResult wrapper |
New type wrapping FileName + Stream | ❌ Rejected — adds type complexity; FileName is just a string, consistent with existing string properties on InteractionInput. |
✅ C. FileName string property on InteractionInput |
public string? FileName { get; internal set; } |
✅ Chosen — consistent with existing string properties (Name, Label, Value, Placeholder). Populated by transport layer (Dashboard sets from uploaded filename, CLI sets from Path.GetFileName). |
Decision 5: CLI file handling
Chosen: Pass user's file path directly (no temp file)
| Option | Description | Outcome |
|---|---|---|
| A. Copy to temp file for consistency | CLI copies user file to temp dir, sends temp path | ❌ Rejected — unnecessary I/O. CLI and AppHost are on the same machine; the user's file is directly accessible. |
| ✅ B. Send user's file path directly | CLI sets Value = user's provided path, FileName = Path.GetFileName(path) |
✅ Chosen — simplest approach. No copying. Consumer opens the user's actual file via OpenFileStreamAsync(). CLI temp files are not registered for cleanup (they're the user's files, not ours to delete). |
Proposed Approach
Two changes layered together:
- Stream-based consumer API via extension method — An async extension method on
InteractionInputgives consumers stream access while keepingInteractionInputitself a simple data container (consistent with existing Aspire patterns where all input values are strings). - Temp file transport — Dashboard saves the uploaded file to a temp directory on disk and sends only the file path + metadata over gRPC. This eliminates the 4MB message size limit entirely. Since Dashboard and AppHost run on the same machine (local dev), the AppHost can read the temp file directly.
API Design Rationale
Aspire's interaction API has a consistent pattern: all input values are string? Value (Boolean → "true", Number → "42", etc.) and InteractionInput is a plain data container with no IDisposable or resource-returning methods. Aspire's public APIs also avoid exposing Stream directly — async ValueTask<T> methods are preferred (like GetConnectionStringAsync, GetValueAsync).
To stay consistent while satisfying davidfowl's stream requirement, we:
- Keep
InteractionInput.Valueas the file path (string — consistent) - Add
InteractionInput.FileNameas a string property (consistent) - Add an async extension method
OpenFileStreamAsync()returningValueTask<Stream?>(followsValueTask<T>convention, keepsInteractionInputclean, future-proofs for gRPC streaming)
Architecture
Browser → [SignalR] → Dashboard Server (saves to temp file)
→ [gRPC: sends file_path + file_name + file_size] → AppHost
→ Consumer calls OpenFileStreamAsync() → FileStream from temp path
For CLI:
User provides file path → CLI sends file_path + file_name + file_size → AppHost
→ Consumer calls OpenFileStreamAsync() → FileStream from user's file directly
Migration Notes
Valuefor FileChooser now holds a file path instead of file content. This is a new, unshipped feature so there's no backward compat concern.- The
MaxFileSizeproperty onInteractionInputstill works — the Dashboard enforces it when saving to the temp file. - No gRPC message size limit concerns since only the path (small string) is sent via
value. - The stream-based async extension method is forward-compatible — when actual gRPC streaming is added later, only the extension method implementation changes. Consumer code stays the same.
FileNameis a new string property onInteractionInput— consistent with the existing all-strings pattern.
Open Questions
- Temp directory location: Use
Path.GetTempPath()/aspire-uploads/or integrate withFileSystemService.TempDirectorydirectly? - Max file size default: Keep 1MB default or increase now that size isn't limited by gRPC? (Disk is cheap, but we should still have a reasonable default.)
- Remote dashboard scenario: If Dashboard runs remotely, temp files won't work. Is this a concern for the initial implementation? (Likely not — local dev is the primary scenario.)
- Binary file support:
OpenFileStreamAsync()returns raw bytes, naturally supporting both text and binary files. The previous string-based approach was text-only. This is an improvement.
|
@davidfowl RE: #10233 (comment), was there a second scenario? 1. reads like a real scenario, while 2. reads like a requirement. |
|
@mitchdenny , @JamesNK gentle ping? |
Summary
Add a new
FileChooserinput type to the Aspire Interaction Service, enabling users to select files via the native OS/browser file picker dialog within interaction prompts. The file content is streamed into memory and passed as the inputValue, with the original filename preserved for display.Screenshot
Motivation
The interaction service currently supports text, secret text, choice, boolean, and number input types. However, there is no way for users to select or upload files through interaction dialogs. This is needed for scenarios such as importing configuration files, uploading certificates, or processing user-provided data files.
Changes
Aspire.Hosting (Server-side model)
FileChoosermember to theInputTypeenumFileChooserinputs inInteractionService.RunValidationAsync(required/empty checks, no additional format constraints)Aspire.Hosting.cs)Proto/gRPC contract
INPUT_TYPE_FILE_CHOOSER = 6to theInputTypeenum indashboard_service.protoDashboard Service (Mapping layer)
FileChoosermapping in bothMapInputTypemethods inDashboardService.csDashboard UI (Blazor frontend)
FileChoosercase inInteractionsInputDialog.razorrendering a read-onlyFluentTextField(showing the selected filename) alongside aFluentInputFilecomponent with a Browse buttonInputFileMode.Stream— no temp files are written to diskFileDisplayNameproperty toInputViewModelto display the original filename whileValueholds the file contentfile-chooser-containerlayoutDialogs.resx)CLI (Aspire.Cli)
FileChooserto the CLI'sInputTypeenum inExtensionBackchannelDataTypes.csFileChoosercase inPipelineCommandBase.cs— falls back to a text prompt for entering a file path in CLI modeNew Playground project
playground/Interactions/— a dedicated playground project for testing interaction service enhancementsAspire.slnxHow to test
Files changed (27 modified + 7 new)
Modified
Aspire.slnx— added Interactions playground projectsrc/Aspire.Hosting/IInteractionService.cs— addedFileChooserenum membersrc/Aspire.Hosting/InteractionService.cs— added validation casesrc/Aspire.Hosting/api/Aspire.Hosting.cs— updated public APIsrc/Aspire.Hosting/Dashboard/proto/dashboard_service.proto— added proto enum valuesrc/Aspire.Hosting/Dashboard/DashboardService.cs— added mappingsrc/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor— added FileChooser UIsrc/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs— added file handlersrc/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css— added stylessrc/Aspire.Dashboard/Model/Interaction/InputViewModel.cs— added FileDisplayNamesrc/Aspire.Dashboard/Resources/Dialogs.resx— added localization stringssrc/Aspire.Dashboard/Resources/Dialogs.Designer.cs— added generated propertiessrc/Aspire.Dashboard/Resources/xlf/Dialogs.*.xlf— updated translation files (12 languages)src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs— added CLI enum membersrc/Aspire.Cli/Commands/PipelineCommandBase.cs— added CLI handlingNew
playground/Interactions/Interactions.AppHost/— new playground project (7 files)